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
+18 -22
View File
@@ -11,16 +11,16 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
import {
buildOpenFileExplorerPatch,
buildToggleFileExplorerPatch,
clampExplorerFilesSplitRatio,
clampTreeRailWidth,
clampExplorerWidth,
clampSidebarWidth,
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
DEFAULT_TREE_RAIL_WIDTH,
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
DEFAULT_SIDEBAR_WIDTH,
MAX_EXPLORER_FILES_SPLIT_RATIO,
MAX_TREE_RAIL_WIDTH,
MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_FILES_SPLIT_RATIO,
MIN_TREE_RAIL_WIDTH,
MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
migratePanelState,
@@ -51,15 +51,16 @@ export type {
SortOption,
} from "./state";
export {
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
DEFAULT_TREE_RAIL_WIDTH,
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
DEFAULT_SIDEBAR_WIDTH,
MAX_EXPLORER_FILES_SPLIT_RATIO,
MAX_TREE_RAIL_WIDTH,
MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_FILES_SPLIT_RATIO,
MIN_TREE_RAIL_WIDTH,
MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
clampTreeRailWidth,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
selectPanelVisibility,
@@ -88,7 +89,7 @@ export interface PanelState {
explorerWidth: number;
explorerSortOption: SortOption;
explorerShowHiddenFiles: boolean;
explorerFilesSplitRatio: number;
treeRailWidth: number;
// Actions
toggleFocusMode: () => void;
@@ -117,7 +118,7 @@ export interface PanelState {
setExplorerWidth: (width: number) => void;
setExplorerSortOption: (option: SortOption) => void;
toggleExplorerShowHiddenFiles: () => void;
setExplorerFilesSplitRatio: (ratio: number) => void;
setTreeRailWidth: (width: number) => void;
}
const DEFAULT_DESKTOP_OPEN = isWeb;
@@ -153,7 +154,7 @@ export const usePanelStore = create<PanelState>()(
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
explorerShowHiddenFiles: true,
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
treeRailWidth: DEFAULT_TREE_RAIL_WIDTH,
toggleFocusMode: () =>
set((state) => ({
@@ -303,16 +304,11 @@ export const usePanelStore = create<PanelState>()(
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
toggleExplorerShowHiddenFiles: () =>
set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })),
setExplorerFilesSplitRatio: (ratio) =>
set({
explorerFilesSplitRatio: Number.isFinite(ratio)
? clampExplorerFilesSplitRatio(ratio)
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
}),
setTreeRailWidth: (width) => set({ treeRailWidth: clampTreeRailWidth(width) }),
}),
{
name: "panel-state",
version: 12,
version: 13,
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }),
partialize: (state) => ({
@@ -326,7 +322,7 @@ export const usePanelStore = create<PanelState>()(
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
explorerShowHiddenFiles: state.explorerShowHiddenFiles,
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
treeRailWidth: state.treeRailWidth,
}),
},
),
@@ -356,7 +352,7 @@ export function usePanelState(isMobile: boolean) {
const explorerTabByCheckout = usePanelStore((state) => state.explorerTabByCheckout);
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const explorerSortOption = usePanelStore((state) => state.explorerSortOption);
const explorerFilesSplitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
const treeRailWidth = usePanelStore((state) => state.treeRailWidth);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const activateExplorerTabForCheckout = usePanelStore(
@@ -364,7 +360,7 @@ export function usePanelState(isMobile: boolean) {
);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const setExplorerSortOption = usePanelStore((state) => state.setExplorerSortOption);
const setExplorerFilesSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio);
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
return {
isAgentListOpen,
@@ -377,12 +373,12 @@ export function usePanelState(isMobile: boolean) {
explorerTabByCheckout,
explorerWidth,
explorerSortOption,
explorerFilesSplitRatio,
treeRailWidth,
setExplorerTab,
setExplorerTabForCheckout,
activateExplorerTabForCheckout,
setExplorerWidth,
setExplorerSortOption,
setExplorerFilesSplitRatio,
setTreeRailWidth,
};
}
@@ -7,6 +7,7 @@ import {
import {
buildOpenFileExplorerPatch,
buildToggleFileExplorerPatch,
DEFAULT_TREE_RAIL_WIDTH,
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
@@ -119,6 +120,13 @@ describe("panel-store migration", () => {
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
});
it("replaces the dead explorer split ratio with the shared tree rail width", () => {
const state = migratePanelState({ explorerFilesSplitRatio: 0.5 }, 12, { isWeb: false });
expect(state.explorerFilesSplitRatio).toBeUndefined();
expect(state.treeRailWidth).toBe(DEFAULT_TREE_RAIL_WIDTH);
});
it("drops persisted compact panel state so cold starts return to content", () => {
const state = migratePanelState(
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
+16 -10
View File
@@ -31,9 +31,9 @@ export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000;
export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
export const DEFAULT_TREE_RAIL_WIDTH = 320;
export const MIN_TREE_RAIL_WIDTH = 200;
export const MAX_TREE_RAIL_WIDTH = 600;
export interface PanelVisibilityState {
isAgentListOpen: boolean;
@@ -70,8 +70,8 @@ export function clampExplorerWidth(width: number): number {
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
}
export function clampExplorerFilesSplitRatio(ratio: number): number {
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
export function clampTreeRailWidth(width: number): number {
return clampNumber(width, MIN_TREE_RAIL_WIDTH, MAX_TREE_RAIL_WIDTH);
}
export function selectPanelVisibility(
@@ -190,6 +190,7 @@ export const PanelPersistedStateSchema = z.strictObject({
explorerSortOption: z.enum(["name", "modified", "size"]).optional(),
explorerShowHiddenFiles: z.boolean().optional(),
explorerFilesSplitRatio: z.number().optional(),
treeRailWidth: z.number().optional(),
});
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
@@ -198,11 +199,6 @@ function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): vo
if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
if (typeof state.explorerFilesSplitRatio !== "number") {
state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
} else {
state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(state.explorerFilesSplitRatio);
}
}
function migratePanelV3Explorer(state: MigratablePanelState, isWeb: boolean): void {
@@ -253,6 +249,15 @@ function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
}
}
function migrateTreeRailWidth(state: MigratablePanelState, version: number): void {
if (version < 13 || typeof state.treeRailWidth !== "number") {
delete state.explorerFilesSplitRatio;
state.treeRailWidth = DEFAULT_TREE_RAIL_WIDTH;
return;
}
state.treeRailWidth = clampTreeRailWidth(state.treeRailWidth);
}
export function migratePanelState(
persistedState: unknown,
version: number,
@@ -302,6 +307,7 @@ export function migratePanelState(
if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true;
}
migrateTreeRailWidth(state, version);
if (version < 12) {
// Compact panel position is transient UI state. Cold starts always begin
// at content, regardless of what an older version persisted.
@@ -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({
@@ -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: {},
});
});
@@ -32,6 +32,7 @@ import {
reorderFocusedPaneTabsInLayout,
reorderPaneTabsInLayout,
retargetTabInLayout,
setPaneHiddenInLayout,
splitPaneEmptyInLayout,
splitPaneInLayout,
stripEphemeralTabsFromLayout,
@@ -75,6 +76,8 @@ interface WorkspaceLayoutStore {
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
explorerPaneIdByWorkspace: Record<string, string | null>;
acknowledgedPullRequestByWorkspace: Record<string, string>;
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
openChildTabFocused: (
workspaceKey: string,
@@ -82,6 +85,12 @@ interface WorkspaceLayoutStore {
parentTabId: string,
) => string | null;
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
ensureExplorerPane: (workspaceKey: string) => EnsureExplorerPaneResult | null;
openTabInExplorerPaneFocused: (
workspaceKey: string,
input: { target: WorkspaceTabTarget; parentTabId?: string | null },
) => string | null;
observePullRequest: (workspaceKey: string, identity: string | null) => void;
closeTab: (workspaceKey: string, tabId: string) => void;
focusTab: (workspaceKey: string, tabId: string) => void;
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
@@ -107,6 +116,9 @@ interface WorkspaceLayoutStore {
) => string | null;
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
focusPane: (workspaceKey: string, paneId: string) => void;
hidePane: (workspaceKey: string, paneId: string) => void;
showPane: (workspaceKey: string, paneId: string) => void;
setExplorerPaneId: (workspaceKey: string, paneId: string | null) => void;
unfocusPane: (workspaceKey: string) => string | null;
restorePaneFocus: (workspaceKey: string, token: string) => void;
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
@@ -118,6 +130,11 @@ interface WorkspaceLayoutStore {
purgeWorkspace: (workspaceKey: string) => void;
}
interface EnsureExplorerPaneResult {
paneId: string;
created: boolean;
}
interface WorkspaceFocusRestorationState {
restorePaneId: string | null;
tokens: string[];
@@ -147,6 +164,8 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
}),
z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }),
z.strictObject({ kind: z.literal("browser"), browserId: z.string() }),
z.strictObject({ kind: z.literal("files") }),
z.strictObject({ kind: z.literal("pull_request") }),
z.strictObject({
kind: z.literal("file"),
path: z.string(),
@@ -179,6 +198,7 @@ const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
tabIds: z.array(z.string()),
focusedTabId: z.string().nullable(),
tabs: z.array(WorkspaceTabStorageSchema).optional(),
hidden: z.boolean().optional(),
}),
}),
z.strictObject({
@@ -200,6 +220,8 @@ const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject(
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
});
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -308,6 +330,8 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
openTabFocused: (workspaceKey, target) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
@@ -408,6 +432,104 @@ export function createWorkspaceLayoutStore(
return result.tabId;
},
ensureExplorerPane: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
return null;
}
const store = get();
const layout = getWorkspaceLayout(store.layoutByWorkspace, normalizedWorkspaceKey);
const explorerPaneId = store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null;
const explorerPane = findPaneById(layout.root, explorerPaneId);
if (explorerPane) {
store.focusPane(normalizedWorkspaceKey, explorerPane.id);
return { paneId: explorerPane.id, created: false };
}
const targetPaneId =
findPaneById(layout.root, layout.focusedPaneId)?.id ??
collectAllPanes(layout.root)[0]?.id;
if (!targetPaneId) {
return null;
}
const paneId = store.splitPaneEmpty(normalizedWorkspaceKey, {
targetPaneId,
position: "right",
});
if (!paneId) {
return null;
}
get().setExplorerPaneId(normalizedWorkspaceKey, paneId);
return { paneId, created: true };
},
openTabInExplorerPaneFocused: (workspaceKey, input) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
if (!normalizedWorkspaceKey || !normalizedTarget) {
return null;
}
const store = get();
const explorerPane = store.ensureExplorerPane(normalizedWorkspaceKey);
if (!explorerPane) {
return null;
}
const parentTabId = trimNonEmpty(input.parentTabId);
const tabId = parentTabId
? get().openChildTabFocused(normalizedWorkspaceKey, normalizedTarget, parentTabId)
: get().openTabFocused(normalizedWorkspaceKey, normalizedTarget);
if (!tabId) {
return null;
}
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
const currentPane = findPaneContainingTab(layout.root, tabId);
if (currentPane?.id !== explorerPane.paneId) {
get().moveTabToPane(normalizedWorkspaceKey, tabId, explorerPane.paneId);
}
return tabId;
},
observePullRequest: (workspaceKey, identity) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedIdentity = trimNonEmpty(identity);
if (!normalizedWorkspaceKey || !normalizedIdentity) {
return;
}
set((state) => {
if (
state.acknowledgedPullRequestByWorkspace[normalizedWorkspaceKey] ===
normalizedIdentity
) {
return state;
}
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerPaneId = state.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null;
const explorerPane = findPaneById(layout.root, explorerPaneId);
const placementLayout = explorerPane
? (focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout)
: layout;
const result = openTabInLayoutFocused({
layout: placementLayout,
target: { kind: "pull_request" },
now: Date.now(),
});
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
acknowledgedPullRequestByWorkspace: {
...state.acknowledgedPullRequestByWorkspace,
[normalizedWorkspaceKey]: normalizedIdentity,
},
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout,
},
};
});
},
closeTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
@@ -722,6 +844,67 @@ export function createWorkspaceLayoutStore(
};
});
},
hidePane: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedPaneId = trimNonEmpty(paneId);
if (!normalizedWorkspaceKey || !normalizedPaneId) {
return;
}
set((state) => {
const nextLayout = setPaneHiddenInLayout({
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
paneId: normalizedPaneId,
hidden: true,
});
if (!nextLayout) {
return state;
}
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
};
});
},
showPane: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedPaneId = trimNonEmpty(paneId);
if (!normalizedWorkspaceKey || !normalizedPaneId) {
return;
}
set((state) => {
const nextLayout = setPaneHiddenInLayout({
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
paneId: normalizedPaneId,
hidden: false,
});
if (!nextLayout) {
return state;
}
return {
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
};
});
},
setExplorerPaneId: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
return;
}
set((state) => ({
explorerPaneIdByWorkspace: {
...state.explorerPaneIdByWorkspace,
[normalizedWorkspaceKey]: trimNonEmpty(paneId),
},
}));
},
unfocusPane: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
@@ -787,7 +970,8 @@ export function createWorkspaceLayoutStore(
};
}
const restorePaneId = findPaneById(layout.root, restoration.restorePaneId)?.id ?? null;
const restorePane = findPaneById(layout.root, restoration.restorePaneId);
const restorePaneId = restorePane?.hidden === true ? null : (restorePane?.id ?? null);
if (!restorePaneId) {
return {
focusRestorationByWorkspace: remainingRestorations,
@@ -991,7 +1175,9 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.focusRestorationByWorkspace;
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
normalizedWorkspaceKey in state.explorerPaneIdByWorkspace ||
normalizedWorkspaceKey in state.acknowledgedPullRequestByWorkspace;
if (!hasAny) {
return state;
}
@@ -1007,6 +1193,12 @@ export function createWorkspaceLayoutStore(
state.hiddenAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
state.focusRestorationByWorkspace;
const { [normalizedWorkspaceKey]: _explorerPane, ...explorerPaneIdByWorkspace } =
state.explorerPaneIdByWorkspace;
const {
[normalizedWorkspaceKey]: _acknowledgedPullRequest,
...acknowledgedPullRequestByWorkspace
} = state.acknowledgedPullRequestByWorkspace;
return {
layoutByWorkspace,
splitSizesByWorkspace,
@@ -1014,6 +1206,8 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace,
hiddenAgentIdsByWorkspace,
focusRestorationByWorkspace,
explorerPaneIdByWorkspace,
acknowledgedPullRequestByWorkspace,
};
});
},
@@ -1034,6 +1228,8 @@ export function createWorkspaceLayoutStore(
return {
layoutByWorkspace,
splitSizesByWorkspace: state.splitSizesByWorkspace,
explorerPaneIdByWorkspace: state.explorerPaneIdByWorkspace,
acknowledgedPullRequestByWorkspace: state.acknowledgedPullRequestByWorkspace,
};
},
merge: (persistedState, currentState) => {
@@ -1045,6 +1241,9 @@ export function createWorkspaceLayoutStore(
...currentState,
layoutByWorkspace: result.data.layoutByWorkspace,
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
explorerPaneIdByWorkspace: result.data.explorerPaneIdByWorkspace ?? {},
acknowledgedPullRequestByWorkspace:
result.data.acknowledgedPullRequestByWorkspace ?? {},
};
},
},