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
@@ -84,6 +84,7 @@ function createPane(input: {
id: string;
tabIds: string[];
focusedTabId?: string | null;
hidden?: boolean;
targetsByTabId?: Record<string, WorkspaceTab["target"]>;
}): SplitNode {
const tabs = input.tabIds.map((tabId) => createTab(tabId, input.targetsByTabId?.[tabId]));
@@ -93,6 +94,7 @@ function createPane(input: {
id: input.id,
tabIds: input.tabIds,
focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null,
...(input.hidden === true ? { hidden: true } : {}),
tabs,
} as SplitPane,
};
@@ -200,6 +202,14 @@ describe("workspace-layout-store helpers", () => {
expect(getFocusedBrowserId({ root, focusedPaneId: "main" })).toBeNull();
});
it("keeps tabs in hidden panes while excluding those panes from focus helpers", () => {
const root = createPane({ id: "hidden", tabIds: ["tab-a"], hidden: true });
expect(collectAllTabs(root).map((tab) => tab.tabId)).toEqual(["tab-a"]);
expect(collectAllPanes(root)).toEqual([]);
expect(getFocusedBrowserId({ root, focusedPaneId: "hidden" })).toBeNull();
});
});
describe("workspace-layout-store tree transforms", () => {
@@ -314,6 +324,8 @@ describe("workspace-layout-store actions", () => {
pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
});
});
@@ -335,6 +347,142 @@ describe("workspace-layout-store actions", () => {
await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull();
});
it("persists first-class pane targets, visibility, and focus", async () => {
await AsyncStorage.removeItem("workspace-layout-state");
const workspaceKey = createWorkspaceKey();
const source = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await source.persist.rehydrate();
source.getState().openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const focusedAgentTabId = source
.getState()
.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-2" });
const explorer = source.getState().ensureExplorerPane(workspaceKey);
expect(explorer).toBeTruthy();
source.getState().openTabFocused(workspaceKey, { kind: "files" });
source.getState().observePullRequest(workspaceKey, "pr:1");
source.getState().hidePane(workspaceKey, explorer!.paneId);
await vi.waitFor(async () => {
expect(await AsyncStorage.getItem("workspace-layout-state")).not.toBeNull();
});
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
const state = restored.getState();
const layout = state.layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main");
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(focusedAgentTabId);
expect(findPaneById(layout.root, explorer!.paneId)?.hidden).toBe(true);
expect(collectAllTabs(layout.root).map((tab) => tab.target.kind)).toEqual([
"agent",
"agent",
"files",
"pull_request",
]);
expect(state.explorerPaneIdByWorkspace[workspaceKey]).toBe(explorer!.paneId);
expect(state.acknowledgedPullRequestByWorkspace[workspaceKey]).toBe("pr:1");
});
it("auto-adds each detected pull request once and respects a deliberate close", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.observePullRequest(workspaceKey, null);
expect(store.getWorkspaceTabs(workspaceKey)).toEqual([]);
store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1");
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.target.kind)).toEqual([
"pull_request",
]);
store.closeTab(workspaceKey, "pull_request");
store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1");
expect(store.getWorkspaceTabs(workspaceKey)).toEqual([]);
store.observePullRequest(workspaceKey, "url:https://example.test/pulls/2");
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.target.kind)).toEqual([
"pull_request",
]);
});
it("places an auto-added pull request in the registered explorer pane", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
useWorkspaceLayoutIds("explorer");
const explorerPaneId = store.splitPaneEmpty(workspaceKey, {
targetPaneId: "main",
position: "right",
});
store.setExplorerPaneId(workspaceKey, explorerPaneId);
store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1");
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe(explorerPaneId);
});
it("opens assistant files in an ensured explorer pane and reveals it on the next open", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
useWorkspaceLayoutIds("explorer");
const tabId = store.openTabInExplorerPaneFocused(workspaceKey, {
target: { kind: "file", path: "/repo/worktree/a.ts" },
parentTabId: "agent_agent-a",
});
const createdState = workspaceLayoutStore.getState();
const explorerPaneId = createdState.explorerPaneIdByWorkspace[workspaceKey];
expect(explorerPaneId).toBe("pane_explorer");
expect(
findPaneContainingTab(createdState.layoutByWorkspace[workspaceKey].root, tabId!)?.id,
).toBe(explorerPaneId);
expect(collectAllTabs(createdState.layoutByWorkspace[workspaceKey].root)).toContainEqual({
tabId,
target: { kind: "file", path: "/repo/worktree/a.ts" },
createdAt: expect.any(Number),
});
store.hidePane(workspaceKey, explorerPaneId!);
store.openTabInExplorerPaneFocused(workspaceKey, {
target: { kind: "file", path: "/repo/worktree/b.ts" },
});
const revealedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(revealedLayout.root, explorerPaneId)?.hidden).toBeUndefined();
expect(findPaneContainingTab(revealedLayout.root, "file_/repo/worktree/b.ts")?.id).toBe(
explorerPaneId,
);
});
it("moves a canonical duplicate file tab into the revealed explorer pane", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
useWorkspaceLayoutIds("explorer");
const tabId = store.openTabFocused(workspaceKey, {
kind: "file",
path: "/repo/worktree/a.ts",
});
const explorerPane = store.ensureExplorerPane(workspaceKey);
store.hidePane(workspaceKey, explorerPane!.paneId);
const duplicateTabId = store.openTabInExplorerPaneFocused(workspaceKey, {
target: { kind: "file", path: "/repo/worktree/a.ts", lineStart: 12 },
parentTabId: "agent_agent-a",
});
const state = workspaceLayoutStore.getState();
const layout = state.layoutByWorkspace[workspaceKey];
expect(duplicateTabId).toBe(tabId);
expect(collectAllTabs(layout.root).filter((tab) => tab.tabId === tabId)).toHaveLength(1);
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPane?.paneId);
expect(findPaneById(layout.root, explorerPane?.paneId)?.hidden).toBeUndefined();
expect(layout.focusedPaneId).toBe(explorerPane?.paneId);
expect(findPaneById(layout.root, explorerPane?.paneId)?.focusedTabId).toBe(tabId);
});
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
const workspaceKey = createWorkspaceKey();
@@ -641,6 +789,36 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, newPaneId)?.focusedTabId).toBe(draftTabId);
});
it("hides and shows a pane without changing its tabs or split sizes", () => {
useWorkspaceLayoutIds("88888888-8888-8888-8888-888888888888");
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "draft", draftId: "main-tab" });
const paneId = store.splitPaneEmpty(workspaceKey, {
targetPaneId: "main",
position: "right",
})!;
store.openTabFocused(workspaceKey, { kind: "working_diff" });
const sizes = expectGroup(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root)
.group.sizes;
store.hidePane(workspaceKey, paneId);
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, paneId)).toMatchObject({
hidden: true,
tabIds: ["working_diff"],
});
expect(expectGroup(layout.root).group.sizes).toEqual(sizes);
expect(layout.focusedPaneId).toBe("main");
store.showPane(workspaceKey, paneId);
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: ["working_diff"] });
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
expect(expectGroup(layout.root).group.sizes).toEqual(sizes);
});
it("focusTab moves workspace focus to the pane containing the tab", () => {
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
const workspaceKey = createWorkspaceKey();
@@ -671,6 +849,44 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
});
it("focusTab reveals a hidden pane and focuses its requested tab without changing sizes", () => {
useWorkspaceLayoutIds("89898989-8989-8989-8989-898989898989");
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const targetTabId = store.openTabFocused(workspaceKey, {
kind: "terminal",
terminalId: "term-hidden",
})!;
const paneId = store.splitPane(workspaceKey, {
tabId: targetTabId,
targetPaneId: "main",
position: "right",
})!;
store.openTabFocused(workspaceKey, { kind: "working_diff" });
store.focusPane(workspaceKey, "main");
const group = expectGroup(
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root,
).group;
store.resizeSplit(workspaceKey, group.id, [0.7, 0.3]);
store.hidePane(workspaceKey, paneId);
const splitSizes = workspaceLayoutStore.getState().splitSizesByWorkspace[workspaceKey];
store.focusTab(workspaceKey, targetTabId);
const state = workspaceLayoutStore.getState();
const layout = state.layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe(paneId);
expect(findPaneById(layout.root, paneId)).toMatchObject({
focusedTabId: targetTabId,
tabIds: [targetTabId, "working_diff"],
});
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
expect(expectGroup(layout.root).group.sizes).toEqual(group.sizes);
expect(state.splitSizesByWorkspace[workspaceKey]).toBe(splitSizes);
});
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
const workspaceKey = createWorkspaceKey();
@@ -946,6 +1162,26 @@ describe("workspace-layout-store actions", () => {
expect(layout.focusedPaneId).toBe(splitPaneId);
});
it("focusPane reveals an explicitly targeted hidden pane", () => {
useWorkspaceLayoutIds("57575757-5757-5757-5757-575757575757");
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const paneId = store.splitPaneEmpty(workspaceKey, {
targetPaneId: "main",
position: "right",
})!;
store.focusPane(workspaceKey, "main");
store.hidePane(workspaceKey, paneId);
store.focusPane(workspaceKey, paneId);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe(paneId);
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
});
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
const workspaceKey = createWorkspaceKey();
@@ -1170,6 +1406,8 @@ describe("workspace-layout-store actions", () => {
expect(persisted).toEqual({
layoutByWorkspace: { [workspaceKey]: layout },
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
});
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
{
@@ -1302,6 +1540,8 @@ describe("workspace-layout-store actions", () => {
expect(partialize?.(state)).toEqual({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
});
});
@@ -1338,6 +1578,8 @@ describe("workspace-layout-store actions", () => {
expect(partialize?.(state)).toEqual({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
});
});