Make the workspace right pane a first-class tab host (#3287)

* feat(app): add persistent workspace side panes

Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry.

* fix(app): localize pull request empty state

* test(app): update explorer pane coverage

* fix(app): preserve explorer pane workflows

* test(app): make explorer tab selection idempotent

* fix(app): persist workspace review pins

* fix(app): preserve sidebar scrim drag hook

* fix(app): restore companion pane interactions

* test(app): follow selected sidebar surface
This commit is contained in:
Mohamed Boudra
2026-08-16 18:18:32 +02:00
committed by GitHub
parent 6dcf6a4b08
commit ab274d635e
81 changed files with 2900 additions and 1051 deletions
@@ -13,6 +13,7 @@ export interface SplitPane {
id: string;
tabIds: string[];
focusedTabId: string | null;
hidden?: boolean;
}
export interface SplitGroup {
@@ -240,6 +241,7 @@ function createPaneNode(input: {
id: string;
tabs?: WorkspaceTab[];
focusedTabId?: string | null;
hidden?: boolean;
}): SplitNodeInternal {
const normalizedTabs = normalizeWorkspaceTabs(input.tabs ?? []);
const tabIds = normalizedTabs.map((tab) => tab.tabId);
@@ -254,6 +256,7 @@ function createPaneNode(input: {
tabs: normalizedTabs,
tabIds,
focusedTabId,
...(input.hidden === true ? { hidden: true } : {}),
},
};
}
@@ -511,7 +514,7 @@ function insertChildIntoGroup(
function listPaneIds(node: SplitNodeInternal): string[] {
if (node.kind === "pane") {
return [node.pane.id];
return node.pane.hidden === true ? [] : [node.pane.id];
}
const next: string[] = [];
for (const child of node.group.children) {
@@ -562,6 +565,7 @@ function normalizePaneAfterTabChange(pane: SplitPaneInternal): SplitPaneInternal
tabs,
tabIds,
focusedTabId,
...(pane.hidden === true ? { hidden: true } : {}),
};
}
@@ -584,6 +588,7 @@ function normalizePaneNode(rawPane: SplitPaneInternal | undefined): SplitNodeInt
id: paneId,
tabs: mergedTabs,
focusedTabId: trimNonEmpty(rawPane?.focusedTabId) ?? null,
hidden: rawPane?.hidden === true,
});
}
@@ -756,6 +761,7 @@ function focusTabInPane(root: SplitNodeInternal, paneId: string, tabId: string):
pane: normalizePaneAfterTabChange({
...node.pane,
focusedTabId: tabId,
hidden: undefined,
}),
};
});
@@ -972,7 +978,7 @@ export function collectAllTabs(root: SplitNode): WorkspaceTab[] {
export function collectAllPanes(root: SplitNode): SplitPane[] {
const internalRoot = asInternalNode(root);
if (internalRoot.kind === "pane") {
return [internalRoot.pane];
return internalRoot.pane.hidden === true ? [] : [internalRoot.pane];
}
return internalRoot.group.children.flatMap((child) => collectAllPanes(child));
}
@@ -995,6 +1001,7 @@ function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal
id: node.pane.id,
tabs: nextTabs,
focusedTabId: node.pane.focusedTabId,
hidden: node.pane.hidden,
});
}
return createGroupNode({
@@ -1028,7 +1035,7 @@ export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined):
return null;
}
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
if (!focusedPane?.focusedTabId) {
if (!focusedPane?.focusedTabId || focusedPane.hidden === true) {
return null;
}
const focusedTab = collectAllTabs(layout.root).find(
@@ -1221,7 +1228,11 @@ export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout
return null;
}
if (pane.focusedTabId === input.tabId && layout.focusedPaneId === pane.id) {
if (
pane.focusedTabId === input.tabId &&
layout.focusedPaneId === pane.id &&
pane.hidden !== true
) {
return null;
}
@@ -1448,7 +1459,8 @@ export function splitPaneEmptyInLayout(
export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null {
const layout = asInternalLayout(input.layout);
const sourcePane = findPaneContainingTab(layout.root, input.tabId);
if (!sourcePane || !findPaneById(layout.root, input.toPaneId)) {
const targetPane = findPaneById(layout.root, input.toPaneId);
if (!sourcePane || !targetPane || targetPane.hidden === true) {
return null;
}
@@ -1472,19 +1484,59 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
}
export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null {
if (!findPaneById(input.layout.root, input.paneId)) {
const pane = findPaneById(input.layout.root, input.paneId);
if (!pane) {
return null;
}
if (input.layout.focusedPaneId === input.paneId) {
if (input.layout.focusedPaneId === input.paneId && pane.hidden !== true) {
return null;
}
return withNormalizedParentTabMap({
root: input.layout.root,
root:
pane.hidden === true
? updatePaneInTree(asInternalNode(input.layout.root), {
paneId: input.paneId,
updater: (currentPane) => ({ ...currentPane, hidden: undefined }),
})
: input.layout.root,
focusedPaneId: input.paneId,
parentTabIdByTabId: input.layout.parentTabIdByTabId,
});
}
export function setPaneHiddenInLayout(input: {
layout: WorkspaceLayout;
paneId: string;
hidden: boolean;
}): WorkspaceLayout | null {
const pane = findPaneById(input.layout.root, input.paneId);
const isHidden = pane?.hidden === true;
if (!pane || isHidden === input.hidden) {
return null;
}
if (input.hidden && collectAllPanes(input.layout.root).length === 1) {
return null;
}
const root = updatePaneInTree(asInternalNode(input.layout.root), {
paneId: input.paneId,
updater: (currentPane) => ({
...currentPane,
hidden: input.hidden ? true : undefined,
}),
});
const focusedPaneId =
input.hidden && input.layout.focusedPaneId === input.paneId
? (collectAllPanes(root)[0]?.id ?? null)
: input.layout.focusedPaneId;
return withNormalizedParentTabMap({
root,
focusedPaneId,
parentTabIdByTabId: input.layout.parentTabIdByTabId,
});
}
export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout {
const layout = asInternalLayout(input.layout);
return withNormalizedParentTabMap({