From 2d0ed004e2d48d5aaa5aa27325ca2061598f882d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 15:34:11 +0800 Subject: [PATCH] Refactor workspace layout id generation (#876) --- .../src/stores/workspace-layout-actions.ts | 21 +- .../app/src/stores/workspace-layout-ids.ts | 17 + .../src/stores/workspace-layout-store.test.ts | 314 +++-- .../app/src/stores/workspace-layout-store.ts | 1221 ++++++++--------- 4 files changed, 796 insertions(+), 777 deletions(-) create mode 100644 packages/app/src/stores/workspace-layout-ids.ts diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 4ff3d36cf..c0d728408 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -1,5 +1,7 @@ import invariant from "tiny-invariant"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids"; +import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids"; import { buildDeterministicWorkspaceTabId, normalizeWorkspaceTabTarget, @@ -86,7 +88,7 @@ interface InsertSplitInternalInput { targetPaneId: string; tabId: string; position: "left" | "right" | "top" | "bottom"; - createNodeId: (prefix: "pane" | "group") => string; + createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; } interface InsertSplitInternalResult { @@ -142,7 +144,7 @@ interface SplitPaneInLayoutInput { tabId: string; targetPaneId: string; position: "left" | "right" | "top" | "bottom"; - createNodeId: (prefix: "pane" | "group") => string; + createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; maxTreeDepth: number; } @@ -155,7 +157,7 @@ interface SplitPaneEmptyInLayoutInput { layout: WorkspaceLayout; targetPaneId: string; position: "left" | "right" | "top" | "bottom"; - createNodeId: (prefix: "pane" | "group") => string; + createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; maxTreeDepth: number; } @@ -232,14 +234,6 @@ function normalizeTabIds(list: unknown): string[] { return next; } -function generateNodeId(prefix: "pane" | "group"): string { - const randomValue = - typeof globalThis.crypto?.randomUUID === "function" - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `${prefix}_${randomValue}`; -} - function createPaneNode(input: { id: string; tabs?: WorkspaceTab[]; @@ -996,13 +990,16 @@ export function insertSplit( targetPaneId: string, tabId: string, position: "left" | "right" | "top" | "bottom", + createNodeId: ( + prefix: WorkspaceLayoutNodeIdPrefix, + ) => string = defaultWorkspaceLayoutIds.createNodeId, ): SplitNode { return insertSplitInternal({ root: asInternalNode(root), targetPaneId, tabId, position, - createNodeId: generateNodeId, + createNodeId, }).root; } diff --git a/packages/app/src/stores/workspace-layout-ids.ts b/packages/app/src/stores/workspace-layout-ids.ts new file mode 100644 index 000000000..ce4009a2b --- /dev/null +++ b/packages/app/src/stores/workspace-layout-ids.ts @@ -0,0 +1,17 @@ +export type WorkspaceLayoutNodeIdPrefix = "pane" | "group"; + +export interface WorkspaceLayoutIdSource { + createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; + createFocusRestorationToken: () => string; +} + +function createRandomIdValue(): string { + return typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export const defaultWorkspaceLayoutIds: WorkspaceLayoutIdSource = { + createNodeId: (prefix) => `${prefix}_${createRandomIdValue()}`, + createFocusRestorationToken: () => `workspace-focus-${createRandomIdValue()}`, +}; diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 7fbe3f73f..d10dc4b2c 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -20,6 +20,7 @@ import { buildWorkspaceTabPersistenceKey, collectAllPanes, collectAllTabs, + createWorkspaceLayoutStore, createDefaultLayout, findPaneById, findPaneContainingTab, @@ -28,7 +29,6 @@ import { insertSplit, removePaneFromTree, removeTabFromTree, - useWorkspaceLayoutStore, type SplitNode, type SplitPane, } from "@/stores/workspace-layout-store"; @@ -36,6 +36,40 @@ import { const SERVER_ID = "server-1"; const WORKSPACE_ID = "ws-main"; +function createDeterministicWorkspaceLayoutIds() { + let values: string[] = []; + let fallbackIndex = 0; + + function nextValue(): string { + const value = values.shift(); + if (value) { + return value; + } + fallbackIndex += 1; + return `generated-${fallbackIndex}`; + } + + return { + useValues: (nextValues: string[]) => { + values = nextValues.slice(); + fallbackIndex = 0; + }, + reset: () => { + values = []; + fallbackIndex = 0; + }, + createNodeId: (prefix: "pane" | "group") => `${prefix}_${nextValue()}`, + createFocusRestorationToken: () => `workspace-focus-${nextValue()}`, + }; +} + +const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds(); +const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds); + +function useWorkspaceLayoutIds(...values: string[]) { + workspaceLayoutIds.useValues(values); +} + function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab { return { tabId, @@ -168,13 +202,14 @@ describe("workspace-layout-store helpers", () => { describe("workspace-layout-store tree transforms", () => { beforeEach(() => { - vi.restoreAllMocks(); + workspaceLayoutIds.reset(); }); it("insertSplit wraps root-level same-direction splits in a nested group", () => { - vi.spyOn(globalThis.crypto, "randomUUID") - .mockReturnValueOnce("11111111-1111-1111-1111-111111111111") - .mockReturnValueOnce("22222222-2222-2222-2222-222222222222"); + useWorkspaceLayoutIds( + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + ); const root: SplitNode = { kind: "group", @@ -189,7 +224,7 @@ describe("workspace-layout-store tree transforms", () => { }, }; - const nextRoot = insertSplit(root, "right", "tab-c", "right"); + const nextRoot = insertSplit(root, "right", "tab-c", "right", workspaceLayoutIds.createNodeId); const nextGroup = expectGroup(nextRoot); const nestedGroup = expectGroup(nextGroup.group.children[1]); @@ -270,22 +305,20 @@ describe("workspace-layout-store tree transforms", () => { describe("workspace-layout-store actions", () => { beforeEach(() => { - useWorkspaceLayoutStore.setState({ + workspaceLayoutIds.reset(); + workspaceLayoutStore.setState({ layoutByWorkspace: {}, splitSizesByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, }); - vi.restoreAllMocks(); }); it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - ); + useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const firstTabId = store.openTabFocused(workspaceKey, { kind: "file", @@ -308,7 +341,7 @@ describe("workspace-layout-store actions", () => { kind: "file", path: "/repo/worktree/b.ts", }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(firstTabId).toBe("file_/repo/worktree/a.ts"); expect(secondTabId).toBe("file_/repo/worktree/b.ts"); @@ -322,14 +355,14 @@ describe("workspace-layout-store actions", () => { it("openTabInBackground inserts a tab without stealing focus", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); const setupTabId = store.openTabInBackground(workspaceKey, { kind: "setup", workspaceId: "ws-main", }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const pane = findPaneById(layout.root, "main")!; expect(agentTabId).toBe("agent_agent-1"); @@ -341,7 +374,7 @@ describe("workspace-layout-store actions", () => { it("openTabInBackground on an existing target is a no-op", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const firstTabId = store.openTabFocused(workspaceKey, { kind: "file", @@ -355,7 +388,7 @@ describe("workspace-layout-store actions", () => { kind: "file", path: "/repo/worktree/a.ts", }); - const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const pane = findPaneById(layoutAfter.root, "main")!; expect(duplicateTabId).toBe(firstTabId); @@ -365,27 +398,25 @@ describe("workspace-layout-store actions", () => { it("unfocuses and restores the previous focused pane", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); const token = store.unfocusPane(workspaceKey); expect(token).toBeTruthy(); expect( - useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, + workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, ).toBeNull(); store.restorePaneFocus(workspaceKey, token!); - expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( + expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( "main", ); }); it("does not restore stale focus after another pane is focused", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", - ); + useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); store.splitPane(workspaceKey, { @@ -399,14 +430,14 @@ describe("workspace-layout-store actions", () => { store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); store.restorePaneFocus(workspaceKey, token!); - expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( + expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", ); }); it("waits for nested focus restorations before restoring", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); const outerToken = store.unfocusPane(workspaceKey); @@ -414,22 +445,22 @@ describe("workspace-layout-store actions", () => { store.restorePaneFocus(workspaceKey, outerToken!); expect( - useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, + workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, ).toBeNull(); store.restorePaneFocus(workspaceKey, innerToken!); - expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( + expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( "main", ); }); it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(firstTabId).toBe("draft-1"); expect(secondTabId).toBe("draft-2"); @@ -450,11 +481,9 @@ describe("workspace-layout-store actions", () => { }); it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce( - "77777777-7777-7777-7777-777777777777", - ); + useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const newPaneId = store.splitPaneEmpty(workspaceKey, { @@ -465,7 +494,7 @@ describe("workspace-layout-store actions", () => { kind: "draft", draftId: "draft-split", }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777"); expect(draftTabId).toBe("draft-split"); @@ -476,11 +505,9 @@ describe("workspace-layout-store actions", () => { }); it("focusTab moves workspace focus to the pane containing the tab", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", - ); + useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const fileTabId = store.openTabFocused(workspaceKey, { kind: "file", @@ -497,22 +524,20 @@ describe("workspace-layout-store actions", () => { }); store.focusTab(workspaceKey, fileTabId!); - let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(layout.focusedPaneId).toBe("main"); store.focusTab(workspaceKey, terminalTabId!); - layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); expect(layout.focusedPaneId).toBe(splitPaneId); expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId); }); it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "12121212-1212-1212-1212-121212121212", - ); + useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" }); @@ -523,7 +548,7 @@ describe("workspace-layout-store actions", () => { }); const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1"); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const splitPane = findPaneById(layout.root, splitPaneId); const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId); @@ -540,7 +565,7 @@ describe("workspace-layout-store actions", () => { it("retargetTab keeps a draft tab in place while updating its target", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const draftTabId = store.openTabFocused(workspaceKey, { kind: "draft", @@ -550,7 +575,7 @@ describe("workspace-layout-store actions", () => { kind: "file", path: "/repo/worktree/retargeted.ts", }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(draftTabId).toBe("draft-retarget"); expect(nextTabId).toBe(draftTabId); @@ -565,11 +590,9 @@ describe("workspace-layout-store actions", () => { }); it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce( - "55555555-5555-5555-5555-555555555555", - ); + useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const existingFileTabId = store.openTabFocused(workspaceKey, { kind: "file", @@ -590,7 +613,7 @@ describe("workspace-layout-store actions", () => { kind: "file", path: "/repo/worktree/existing.ts", }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts"); expect(draftTabId).toBe("draft-dup"); @@ -606,7 +629,7 @@ describe("workspace-layout-store actions", () => { it("retargetTab closes a draft tab and focuses an existing matching target tab", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const firstDraftTabId = store.openTabFocused(workspaceKey, { kind: "draft", @@ -625,7 +648,7 @@ describe("workspace-layout-store actions", () => { kind: "agent", agentId: "agent-1", }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(firstAgentTabId).toBe(firstDraftTabId); expect(nextTabId).toBe(firstDraftTabId); @@ -641,7 +664,7 @@ describe("workspace-layout-store actions", () => { it("reorderTabs reorders tabs within the focused pane", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const firstTabId = store.openTabFocused(workspaceKey, { kind: "file", @@ -657,7 +680,7 @@ describe("workspace-layout-store actions", () => { }); store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(findPaneById(layout.root, "main")).toEqual({ id: "main", @@ -684,11 +707,9 @@ describe("workspace-layout-store actions", () => { }); it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "34343434-3434-3434-3434-343434343434", - ); + useWorkspaceLayoutIds("34343434-3434-3434-3434-343434343434"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { @@ -712,7 +733,7 @@ describe("workspace-layout-store actions", () => { store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!); store.focusPane(workspaceKey, "main"); store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434"); expect(layout.focusedPaneId).toBe("main"); @@ -736,11 +757,9 @@ describe("workspace-layout-store actions", () => { }); it("focusPane switches workspace focus to a different pane", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "56565656-5656-5656-5656-565656565656", - ); + useWorkspaceLayoutIds("56565656-5656-5656-5656-565656565656"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const secondTabId = store.openTabFocused(workspaceKey, { @@ -754,22 +773,20 @@ describe("workspace-layout-store actions", () => { }); store.focusPane(workspaceKey, "main"); - let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(layout.focusedPaneId).toBe("main"); store.focusPane(workspaceKey, splitPaneId!); - layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656"); expect(layout.focusedPaneId).toBe(splitPaneId); }); it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "cccccccc-cccc-cccc-cccc-cccccccccccc", - ); + useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const secondTabId = store.openTabFocused(workspaceKey, { @@ -783,7 +800,7 @@ describe("workspace-layout-store actions", () => { }); store.closeTab(workspaceKey, secondTabId!); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc"); expect(layout.focusedPaneId).toBe("main"); @@ -791,18 +808,19 @@ describe("workspace-layout-store actions", () => { }); it("splitPane enforces the maximum depth of four", () => { - vi.spyOn(globalThis.crypto, "randomUUID") - .mockReturnValueOnce("11111111-1111-1111-1111-111111111111") - .mockReturnValueOnce("22222222-2222-2222-2222-222222222222") - .mockReturnValueOnce("33333333-3333-3333-3333-333333333333") - .mockReturnValueOnce("44444444-4444-4444-4444-444444444444") - .mockReturnValueOnce("55555555-5555-5555-5555-555555555555") - .mockReturnValueOnce("66666666-6666-6666-6666-666666666666") - .mockReturnValueOnce("77777777-7777-7777-7777-777777777777") - .mockReturnValueOnce("88888888-8888-8888-8888-888888888888"); + useWorkspaceLayoutIds( + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + "33333333-3333-3333-3333-333333333333", + "44444444-4444-4444-4444-444444444444", + "55555555-5555-5555-5555-555555555555", + "66666666-6666-6666-6666-666666666666", + "77777777-7777-7777-7777-777777777777", + "88888888-8888-8888-8888-888888888888", + ); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); @@ -831,7 +849,7 @@ describe("workspace-layout-store actions", () => { position: "bottom", }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111"); expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333"); expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555"); @@ -840,11 +858,9 @@ describe("workspace-layout-store actions", () => { }); it("moveTabToPane collapses the source pane when its last tab moves out", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "dddddddd-dddd-dddd-dddd-dddddddddddd", - ); + useWorkspaceLayoutIds("dddddddd-dddd-dddd-dddd-dddddddddddd"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const leftTabId = store.openTabFocused(workspaceKey, { kind: "file", @@ -861,7 +877,7 @@ describe("workspace-layout-store actions", () => { }); store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(layout.focusedPaneId).toBe(splitPaneId); expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]); @@ -872,13 +888,14 @@ describe("workspace-layout-store actions", () => { }); it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => { - vi.spyOn(globalThis.crypto, "randomUUID") - .mockReturnValueOnce("78787878-7878-7878-7878-787878787878") - .mockReturnValueOnce("89898989-8989-8989-8989-898989898989") - .mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a"); + useWorkspaceLayoutIds( + "78787878-7878-7878-7878-787878787878", + "89898989-8989-8989-8989-898989898989", + "9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a", + ); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const secondTabId = store.openTabFocused(workspaceKey, { @@ -901,7 +918,7 @@ describe("workspace-layout-store actions", () => { }); store.closeTab(workspaceKey, secondTabId!); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const rootGroup = expectGroup(layout.root); expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878"); @@ -937,11 +954,9 @@ describe("workspace-layout-store actions", () => { }); it("openTab focuses the existing tab instead of creating a duplicate entry", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "abababab-abab-abab-abab-abababababab", - ); + useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const secondTabId = store.openTabFocused(workspaceKey, { @@ -959,7 +974,7 @@ describe("workspace-layout-store actions", () => { kind: "file", path: "/repo/worktree/b.ts", }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab"); expect(duplicateTabId).toBe(secondTabId); @@ -971,13 +986,14 @@ describe("workspace-layout-store actions", () => { }); it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => { - vi.spyOn(globalThis.crypto, "randomUUID") - .mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee") - .mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff") - .mockReturnValueOnce("11111111-1111-1111-1111-111111111111"); + useWorkspaceLayoutIds( + "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + "11111111-1111-1111-1111-111111111111", + ); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); @@ -995,12 +1011,12 @@ describe("workspace-layout-store actions", () => { position: "right", }); - const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; + const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const splitGroup = expectGroup(splitRoot); const nestedGroup = expectGroup(splitGroup.group.children[1]); store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]); - const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; + const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const resizedGroup = expectGroup(resizedRoot); const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]); const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0); @@ -1014,11 +1030,11 @@ describe("workspace-layout-store actions", () => { it("closing the last tab keeps a single empty pane in the layout", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); store.closeTab(workspaceKey, tabId!); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(layout).toEqual(createDefaultLayout()); }); @@ -1032,12 +1048,12 @@ describe("workspace-layout-store actions", () => { expect(otherWorkspaceKey).toBeTruthy(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.pinAgent(workspaceKey, "agent-1"); store.pinAgent(workspaceKey, "agent-1"); store.pinAgent(otherWorkspaceKey as string, "agent-2"); - let state = useWorkspaceLayoutStore.getState(); + let state = workspaceLayoutStore.getState(); expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ "agent-2", @@ -1045,13 +1061,13 @@ describe("workspace-layout-store actions", () => { store.unpinAgent(workspaceKey, "agent-1"); - state = useWorkspaceLayoutStore.getState(); + state = workspaceLayoutStore.getState(); expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ "agent-2", ]); - const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize; + const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); expect(partialize?.(state)).toEqual({ layoutByWorkspace: {}, @@ -1068,12 +1084,12 @@ describe("workspace-layout-store actions", () => { expect(otherWorkspaceKey).toBeTruthy(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(otherWorkspaceKey as string, "agent-2"); - let state = useWorkspaceLayoutStore.getState(); + let state = workspaceLayoutStore.getState(); expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ "agent-2", @@ -1081,13 +1097,13 @@ describe("workspace-layout-store actions", () => { store.unhideAgent(workspaceKey, "agent-1"); - state = useWorkspaceLayoutStore.getState(); + state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ "agent-2", ]); - const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize; + const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); expect(partialize?.(state)).toEqual({ layoutByWorkspace: {}, @@ -1096,11 +1112,9 @@ describe("workspace-layout-store actions", () => { }); it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "67676767-6767-6767-6767-676767676767", - ); + useWorkspaceLayoutIds("67676767-6767-6767-6767-676767676767"); const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const draftTabId = store.openTabFocused(workspaceKey, { kind: "draft", @@ -1114,7 +1128,7 @@ describe("workspace-layout-store actions", () => { }); const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1"); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767"); expect(nextTabId).toBe("agent_agent-1"); @@ -1126,7 +1140,7 @@ describe("workspace-layout-store actions", () => { it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => { const workspaceKey = createWorkspaceKey(); - useWorkspaceLayoutStore.setState((state) => ({ + workspaceLayoutStore.setState((state) => ({ ...state, layoutByWorkspace: { ...state.layoutByWorkspace, @@ -1169,7 +1183,7 @@ describe("workspace-layout-store actions", () => { }, })); - useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: ["agent-1"], @@ -1179,7 +1193,7 @@ describe("workspace-layout-store actions", () => { hasActivePendingDraftCreate: false, }); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const tabs = collectAllTabs(layout.root); expect(tabs.map((tab) => tab.tabId)).toEqual([ @@ -1200,14 +1214,14 @@ describe("workspace-layout-store actions", () => { it("reconcileTabs does not re-add locally hidden agent tabs", () => { const workspaceKey = createWorkspaceKey(); - useWorkspaceLayoutStore.setState((state) => ({ + workspaceLayoutStore.setState((state) => ({ ...state, hiddenAgentIdsByWorkspace: { [workspaceKey]: new Set(["agent-1"]), }, })); - useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: ["agent-1"], @@ -1217,13 +1231,13 @@ describe("workspace-layout-store actions", () => { hasActivePendingDraftCreate: false, }); - expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); + expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); }); it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => { const workspaceKey = createWorkspaceKey(); - useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: ["parent-agent", "child-agent"], @@ -1234,7 +1248,7 @@ describe("workspace-layout-store actions", () => { }); expect( - useWorkspaceLayoutStore + workspaceLayoutStore .getState() .getWorkspaceTabs(workspaceKey) .map((tab) => tab.tabId), @@ -1243,7 +1257,7 @@ describe("workspace-layout-store actions", () => { it("reconcileTabs keeps manually opened subagent tabs that remain active", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); @@ -1258,7 +1272,7 @@ describe("workspace-layout-store actions", () => { }); expect( - useWorkspaceLayoutStore + workspaceLayoutStore .getState() .getWorkspaceTabs(workspaceKey) .map((tab) => tab.tabId), @@ -1267,7 +1281,7 @@ describe("workspace-layout-store actions", () => { it("reconcileTabs prunes archived subagent tabs that are no longer active", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); @@ -1282,7 +1296,7 @@ describe("workspace-layout-store actions", () => { }); expect( - useWorkspaceLayoutStore + workspaceLayoutStore .getState() .getWorkspaceTabs(workspaceKey) .map((tab) => tab.tabId), @@ -1291,7 +1305,7 @@ describe("workspace-layout-store actions", () => { it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.hideAgent(workspaceKey, "child-agent"); store.reconcileTabs(workspaceKey, { @@ -1304,11 +1318,11 @@ describe("workspace-layout-store actions", () => { hasActivePendingDraftCreate: false, }); - expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); + expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); - const state = useWorkspaceLayoutStore.getState(); + const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ "agent_child-agent", @@ -1317,7 +1331,7 @@ describe("workspace-layout-store actions", () => { it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); const scriptTabId = store.openTabFocused(workspaceKey, { kind: "terminal", @@ -1335,8 +1349,8 @@ describe("workspace-layout-store actions", () => { hasActivePendingDraftCreate: false, }); - const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey); + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]); expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId); }); @@ -1344,7 +1358,7 @@ describe("workspace-layout-store actions", () => { it("reconcileTabs does not auto-open live non-standalone terminals", () => { const workspaceKey = createWorkspaceKey(); - useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: [], @@ -1355,46 +1369,44 @@ describe("workspace-layout-store actions", () => { hasActivePendingDraftCreate: false, }); - expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); + expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); }); it("explicitly opening an agent tab clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.hideAgent(workspaceKey, "agent-1"); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); - const state = useWorkspaceLayoutStore.getState(); + const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]); }); it("pinning an agent clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.hideAgent(workspaceKey, "agent-1"); - expect( - useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey], - ).toBeDefined(); + expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined(); store.pinAgent(workspaceKey, "agent-1"); - const state = useWorkspaceLayoutStore.getState(); + const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); }); it("retargeting a tab to an agent clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); - const store = useWorkspaceLayoutStore.getState(); + const store = workspaceLayoutStore.getState(); store.hideAgent(workspaceKey, "agent-1"); const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" }); - const state = useWorkspaceLayoutStore.getState(); + const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); }); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index a8fb07a84..5e15be37d 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -7,6 +7,10 @@ import { type WorkspaceTab, type WorkspaceTabTarget, } from "@/stores/workspace-tabs-store"; +import { + defaultWorkspaceLayoutIds, + type WorkspaceLayoutIdSource, +} from "@/stores/workspace-layout-ids"; import { clampNormalizedSizes, closeTabInLayout, @@ -172,14 +176,6 @@ function getWorkspaceLayout( return normalizeLayout(state[workspaceKey] ?? createDefaultLayout()); } -function createFocusRestorationToken(): string { - const randomValue = - typeof globalThis.crypto?.randomUUID === "function" - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `workspace-focus-${randomValue}`; -} - function withoutFocusRestoration( state: WorkspaceLayoutStore, workspaceKey: string, @@ -192,667 +188,664 @@ function withoutFocusRestoration( return { focusRestorationByWorkspace }; } -export const useWorkspaceLayoutStore = create()( - persist( - (set, get) => ({ - layoutByWorkspace: {}, - splitSizesByWorkspace: {}, - pinnedAgentIdsByWorkspace: {}, - hiddenAgentIdsByWorkspace: {}, - focusRestorationByWorkspace: {}, - openTabFocused: (workspaceKey, target) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTarget = normalizeWorkspaceTabTarget(target); - if (!normalizedWorkspaceKey || !normalizedTarget) { - return null; - } - - const result = openTabInLayoutFocused({ - layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), - target: normalizedTarget, - now: Date.now(), - }); - - set((state) => ({ - ...withoutFocusRestoration(state, normalizedWorkspaceKey), - hiddenAgentIdsByWorkspace: - normalizedTarget.kind !== "agent" - ? state.hiddenAgentIdsByWorkspace - : removeAgentIdFromWorkspaceSet( - state.hiddenAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedTarget.agentId, - ), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: result.layout, - }, - })); - - return result.tabId; - }, - openTabInBackground: (workspaceKey, target) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTarget = normalizeWorkspaceTabTarget(target); - if (!normalizedWorkspaceKey || !normalizedTarget) { - return null; - } - - const result = openTabInLayoutBackground({ - layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), - target: normalizedTarget, - now: Date.now(), - }); - - set((state) => ({ - hiddenAgentIdsByWorkspace: - normalizedTarget.kind !== "agent" - ? state.hiddenAgentIdsByWorkspace - : removeAgentIdFromWorkspaceSet( - state.hiddenAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedTarget.agentId, - ), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: result.layout, - }, - })); - - return result.tabId; - }, - closeTab: (workspaceKey, tabId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTabId = trimNonEmpty(tabId); - if (!normalizedWorkspaceKey || !normalizedTabId) { - return; - } - - set((state) => { - const nextLayout = closeTabInLayout({ - layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), - tabId: normalizedTabId, - }); - if (!nextLayout) { - return state; +export function createWorkspaceLayoutStore( + ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds, +) { + return create()( + persist( + (set, get) => ({ + layoutByWorkspace: {}, + splitSizesByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, + hiddenAgentIdsByWorkspace: {}, + focusRestorationByWorkspace: {}, + openTabFocused: (workspaceKey, target) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTarget = normalizeWorkspaceTabTarget(target); + if (!normalizedWorkspaceKey || !normalizedTarget) { + return null; } - return { + const result = openTabInLayoutFocused({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + target: normalizedTarget, + now: Date.now(), + }); + + set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + hiddenAgentIdsByWorkspace: + normalizedTarget.kind !== "agent" + ? state.hiddenAgentIdsByWorkspace + : removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedTarget.agentId, + ), layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextLayout, + [normalizedWorkspaceKey]: result.layout, }, - }; - }); - }, - focusTab: (workspaceKey, tabId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTabId = trimNonEmpty(tabId); - if (!normalizedWorkspaceKey || !normalizedTabId) { - return; - } + })); - set((state) => { - const nextLayout = focusTabInLayout({ - layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), - tabId: normalizedTabId, + return result.tabId; + }, + openTabInBackground: (workspaceKey, target) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTarget = normalizeWorkspaceTabTarget(target); + if (!normalizedWorkspaceKey || !normalizedTarget) { + return null; + } + + const result = openTabInLayoutBackground({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + target: normalizedTarget, + now: Date.now(), }); - if (!nextLayout) { - return state; - } - return { - ...withoutFocusRestoration(state, normalizedWorkspaceKey), + set((state) => ({ + hiddenAgentIdsByWorkspace: + normalizedTarget.kind !== "agent" + ? state.hiddenAgentIdsByWorkspace + : removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedTarget.agentId, + ), layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextLayout, + [normalizedWorkspaceKey]: result.layout, }, - }; - }); - }, - retargetTab: (workspaceKey, tabId, target) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTabId = trimNonEmpty(tabId); - const normalizedTarget = normalizeWorkspaceTabTarget(target); - if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) { - return null; - } + })); - const result = retargetTabInLayout({ - layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), - tabId: normalizedTabId, - target: normalizedTarget, - }); - if (!result) { - return null; - } - - set((state) => ({ - ...(result.layout.focusedPaneId !== null - ? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {}) - : {}), - hiddenAgentIdsByWorkspace: - normalizedTarget.kind !== "agent" - ? state.hiddenAgentIdsByWorkspace - : removeAgentIdFromWorkspaceSet( - state.hiddenAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedTarget.agentId, - ), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: result.layout, - }, - })); - - return result.tabId; - }, - convertDraftToAgent: (workspaceKey, tabId, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTabId = trimNonEmpty(tabId); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedAgentId) { - return null; - } - - const result = convertDraftToAgentInLayout({ - layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), - tabId: normalizedTabId, - agentId: normalizedAgentId, - }); - if (!result) { - return null; - } - - set((state) => ({ - ...(result.layout.focusedPaneId !== null - ? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {}) - : {}), - hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.hiddenAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: result.layout, - }, - })); - - return result.tabId; - }, - reconcileTabs: (workspaceKey, snapshot) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) { - return; - } - - set((state) => { - const currentLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); - const nextState = reconcileWorkspaceTabs( - { - layout: currentLayout, - pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, - hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, - }, - snapshot, - ); - if (nextState.layout === currentLayout) { - return state; + return result.tabId; + }, + closeTab: (workspaceKey, tabId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + if (!normalizedWorkspaceKey || !normalizedTabId) { + return; } - return { - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextState.layout, - }, - }; - }); - }, - reorderTabs: (workspaceKey, tabIds) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) { - return; - } + set((state) => { + const nextLayout = closeTabInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + }); + if (!nextLayout) { + return state; + } - set((state) => { - const nextLayout = reorderFocusedPaneTabsInLayout({ - layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), - tabIds, - }); - if (!nextLayout) { - return state; - } - - return { - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextLayout, - }, - }; - }); - }, - getWorkspaceTabs: (workspaceKey) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) { - return []; - } - return collectAllTabs( - getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey).root, - ); - }, - splitPane: (workspaceKey, input) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTabId = trimNonEmpty(input.tabId); - const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId); - if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTargetPaneId) { - return null; - } - - const result = splitPaneInLayout({ - layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), - tabId: normalizedTabId, - targetPaneId: normalizedTargetPaneId, - position: input.position, - maxTreeDepth: MAX_TREE_DEPTH, - createNodeId: (prefix) => { - const randomValue = - typeof globalThis.crypto?.randomUUID === "function" - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `${prefix}_${randomValue}`; - }, - }); - if (!result) { - return null; - } - - set((state) => ({ - ...withoutFocusRestoration(state, normalizedWorkspaceKey), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: result.layout, - }, - })); - - return result.paneId; - }, - splitPaneEmpty: (workspaceKey, input) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId); - if (!normalizedWorkspaceKey || !normalizedTargetPaneId) { - return null; - } - - const result = splitPaneEmptyInLayout({ - layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), - targetPaneId: normalizedTargetPaneId, - position: input.position, - maxTreeDepth: MAX_TREE_DEPTH, - createNodeId: (prefix) => { - const randomValue = - typeof globalThis.crypto?.randomUUID === "function" - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `${prefix}_${randomValue}`; - }, - }); - if (!result) { - return null; - } - - set((state) => ({ - ...withoutFocusRestoration(state, normalizedWorkspaceKey), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: result.layout, - }, - })); - - return result.paneId; - }, - moveTabToPane: (workspaceKey, tabId, toPaneId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTabId = trimNonEmpty(tabId); - const normalizedToPaneId = trimNonEmpty(toPaneId); - if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedToPaneId) { - return; - } - - set((state) => { - const nextLayout = moveTabToPaneInLayout({ - layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), - tabId: normalizedTabId, - toPaneId: normalizedToPaneId, - }); - if (!nextLayout) { - return state; - } - - return { - ...withoutFocusRestoration(state, normalizedWorkspaceKey), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextLayout, - }, - }; - }); - }, - focusPane: (workspaceKey, paneId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedPaneId = trimNonEmpty(paneId); - if (!normalizedWorkspaceKey || !normalizedPaneId) { - return; - } - - set((state) => { - const nextLayout = focusPaneInLayout({ - layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), - paneId: normalizedPaneId, - }); - if (!nextLayout) { - return state; - } - - return { - ...withoutFocusRestoration(state, normalizedWorkspaceKey), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextLayout, - }, - }; - }); - }, - unfocusPane: (workspaceKey) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) { - return null; - } - - const token = createFocusRestorationToken(); - set((state) => { - const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); - const currentRestoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey]; - const restorePaneId = currentRestoration?.restorePaneId ?? layout.focusedPaneId; - - return { - focusRestorationByWorkspace: { - ...state.focusRestorationByWorkspace, - [normalizedWorkspaceKey]: { - restorePaneId, - tokens: [...(currentRestoration?.tokens ?? []), token], - }, - }, - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: - layout.focusedPaneId === null ? layout : { ...layout, focusedPaneId: null }, - }, - }; - }); - return token; - }, - restorePaneFocus: (workspaceKey, token) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedToken = trimNonEmpty(token); - if (!normalizedWorkspaceKey || !normalizedToken) { - return; - } - - set((state) => { - const restoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey]; - if (!restoration?.tokens.includes(normalizedToken)) { - return state; - } - - const nextTokens = restoration.tokens.filter((entry) => entry !== normalizedToken); - const { [normalizedWorkspaceKey]: _removed, ...remainingRestorations } = - state.focusRestorationByWorkspace; - const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); - - if (layout.focusedPaneId !== null) { return { - focusRestorationByWorkspace: remainingRestorations, - }; - } - - if (nextTokens.length > 0) { - return { - focusRestorationByWorkspace: { - ...remainingRestorations, - [normalizedWorkspaceKey]: { - restorePaneId: restoration.restorePaneId, - tokens: nextTokens, - }, + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: nextLayout, }, }; - } - - const restorePaneId = findPaneById(layout.root, restoration.restorePaneId)?.id ?? null; - if (!restorePaneId) { - return { - focusRestorationByWorkspace: remainingRestorations, - }; - } - - return { - focusRestorationByWorkspace: remainingRestorations, - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: { - ...layout, - focusedPaneId: restorePaneId, - }, - }, - }; - }); - }, - resizeSplit: (workspaceKey, groupId, sizes) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedGroupId = trimNonEmpty(groupId); - if (!normalizedWorkspaceKey || !normalizedGroupId) { - return; - } - - set((state) => ({ - splitSizesByWorkspace: { - ...state.splitSizesByWorkspace, - [normalizedWorkspaceKey]: { - ...state.splitSizesByWorkspace[normalizedWorkspaceKey], - [normalizedGroupId]: clampNormalizedSizes(sizes), - }, - }, - })); - }, - reorderTabsInPane: (workspaceKey, paneId, tabIds) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedPaneId = trimNonEmpty(paneId); - if (!normalizedWorkspaceKey || !normalizedPaneId) { - return; - } - - set((state) => { - const nextLayout = reorderPaneTabsInLayout({ - layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), - paneId: normalizedPaneId, - tabIds, }); - if (!nextLayout) { - return state; + }, + focusTab: (workspaceKey, tabId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + if (!normalizedWorkspaceKey || !normalizedTabId) { + return; } - return { - ...withoutFocusRestoration(state, normalizedWorkspaceKey), + set((state) => { + const nextLayout = focusTabInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + }); + if (!nextLayout) { + return state; + } + + return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: nextLayout, + }, + }; + }); + }, + retargetTab: (workspaceKey, tabId, target) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + const normalizedTarget = normalizeWorkspaceTabTarget(target); + if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) { + return null; + } + + const result = retargetTabInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + target: normalizedTarget, + }); + if (!result) { + return null; + } + + set((state) => ({ + ...(result.layout.focusedPaneId !== null + ? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {}) + : {}), + hiddenAgentIdsByWorkspace: + normalizedTarget.kind !== "agent" + ? state.hiddenAgentIdsByWorkspace + : removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedTarget.agentId, + ), layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextLayout, + [normalizedWorkspaceKey]: result.layout, }, - }; - }); - }, - pinAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } + })); - set((state) => { - const currentPinnedAgentIds = - state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; - if (currentPinnedAgentIds?.has(normalizedAgentId)) { - return state; + return result.tabId; + }, + convertDraftToAgent: (workspaceKey, tabId, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedAgentId) { + return null; } - const nextPinnedAgentIds = new Set(currentPinnedAgentIds ?? []); - nextPinnedAgentIds.add(normalizedAgentId); + const result = convertDraftToAgentInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + agentId: normalizedAgentId, + }); + if (!result) { + return null; + } - return { + set((state) => ({ + ...(result.layout.focusedPaneId !== null + ? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {}) + : {}), hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( state.hiddenAgentIdsByWorkspace, normalizedWorkspaceKey, normalizedAgentId, ), - pinnedAgentIdsByWorkspace: { - ...state.pinnedAgentIdsByWorkspace, - [normalizedWorkspaceKey]: nextPinnedAgentIds, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: result.layout, }, - }; - }); - }, - unpinAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } + })); - set((state) => { - const currentPinnedAgentIds = - state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; - if (!currentPinnedAgentIds?.has(normalizedAgentId)) { - return state; + return result.tabId; + }, + reconcileTabs: (workspaceKey, snapshot) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return; } - if (currentPinnedAgentIds.size === 1) { - const nextPinnedAgentIdsByWorkspace = { - ...state.pinnedAgentIdsByWorkspace, - }; - delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey]; + set((state) => { + const currentLayout = getWorkspaceLayout( + state.layoutByWorkspace, + normalizedWorkspaceKey, + ); + const nextState = reconcileWorkspaceTabs( + { + layout: currentLayout, + pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, + hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, + }, + snapshot, + ); + if (nextState.layout === currentLayout) { + return state; + } + return { - pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: nextState.layout, + }, }; + }); + }, + reorderTabs: (workspaceKey, tabIds) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return; } - const nextPinnedAgentIds = new Set(currentPinnedAgentIds); - nextPinnedAgentIds.delete(normalizedAgentId); + set((state) => { + const nextLayout = reorderFocusedPaneTabsInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + tabIds, + }); + if (!nextLayout) { + return state; + } - return { - pinnedAgentIdsByWorkspace: { - ...state.pinnedAgentIdsByWorkspace, - [normalizedWorkspaceKey]: nextPinnedAgentIds, + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: nextLayout, + }, + }; + }); + }, + getWorkspaceTabs: (workspaceKey) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return []; + } + return collectAllTabs( + getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey).root, + ); + }, + splitPane: (workspaceKey, input) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(input.tabId); + const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId); + if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTargetPaneId) { + return null; + } + + const result = splitPaneInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + targetPaneId: normalizedTargetPaneId, + position: input.position, + maxTreeDepth: MAX_TREE_DEPTH, + createNodeId: ids.createNodeId, + }); + if (!result) { + return null; + } + + set((state) => ({ + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: result.layout, }, - }; - }); - }, - hideAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } + })); - set((state) => { - const nextHiddenAgentIdsByWorkspace = addAgentIdToWorkspaceSet( - state.hiddenAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ); - if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) { - return state; + return result.paneId; + }, + splitPaneEmpty: (workspaceKey, input) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId); + if (!normalizedWorkspaceKey || !normalizedTargetPaneId) { + return null; } - return { - hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace, - }; - }); - }, - unhideAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } - - set((state) => { - const nextHiddenAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( - state.hiddenAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ); - if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) { - return state; + const result = splitPaneEmptyInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + targetPaneId: normalizedTargetPaneId, + position: input.position, + maxTreeDepth: MAX_TREE_DEPTH, + createNodeId: ids.createNodeId, + }); + if (!result) { + return null; } - return { - hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace, - }; - }); - }, - purgeWorkspace: (workspaceKey) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) { - return; - } + set((state) => ({ + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: result.layout, + }, + })); - set((state) => { - const hasAny = - normalizedWorkspaceKey in state.layoutByWorkspace || - normalizedWorkspaceKey in state.splitSizesByWorkspace || - normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || - normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || - normalizedWorkspaceKey in state.focusRestorationByWorkspace; - if (!hasAny) { - return state; + return result.paneId; + }, + moveTabToPane: (workspaceKey, tabId, toPaneId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + const normalizedToPaneId = trimNonEmpty(toPaneId); + if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedToPaneId) { + return; + } + + set((state) => { + const nextLayout = moveTabToPaneInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + toPaneId: normalizedToPaneId, + }); + if (!nextLayout) { + return state; + } + + return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: nextLayout, + }, + }; + }); + }, + focusPane: (workspaceKey, paneId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedPaneId = trimNonEmpty(paneId); + if (!normalizedWorkspaceKey || !normalizedPaneId) { + return; + } + + set((state) => { + const nextLayout = focusPaneInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + paneId: normalizedPaneId, + }); + if (!nextLayout) { + return state; + } + + return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: nextLayout, + }, + }; + }); + }, + unfocusPane: (workspaceKey) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return null; + } + + const token = ids.createFocusRestorationToken(); + set((state) => { + const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); + const currentRestoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey]; + const restorePaneId = currentRestoration?.restorePaneId ?? layout.focusedPaneId; + + return { + focusRestorationByWorkspace: { + ...state.focusRestorationByWorkspace, + [normalizedWorkspaceKey]: { + restorePaneId, + tokens: [...(currentRestoration?.tokens ?? []), token], + }, + }, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: + layout.focusedPaneId === null ? layout : { ...layout, focusedPaneId: null }, + }, + }; + }); + return token; + }, + restorePaneFocus: (workspaceKey, token) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedToken = trimNonEmpty(token); + if (!normalizedWorkspaceKey || !normalizedToken) { + return; + } + + set((state) => { + const restoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey]; + if (!restoration?.tokens.includes(normalizedToken)) { + return state; + } + + const nextTokens = restoration.tokens.filter((entry) => entry !== normalizedToken); + const { [normalizedWorkspaceKey]: _removed, ...remainingRestorations } = + state.focusRestorationByWorkspace; + const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); + + if (layout.focusedPaneId !== null) { + return { + focusRestorationByWorkspace: remainingRestorations, + }; + } + + if (nextTokens.length > 0) { + return { + focusRestorationByWorkspace: { + ...remainingRestorations, + [normalizedWorkspaceKey]: { + restorePaneId: restoration.restorePaneId, + tokens: nextTokens, + }, + }, + }; + } + + const restorePaneId = findPaneById(layout.root, restoration.restorePaneId)?.id ?? null; + if (!restorePaneId) { + return { + focusRestorationByWorkspace: remainingRestorations, + }; + } + + return { + focusRestorationByWorkspace: remainingRestorations, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + ...layout, + focusedPaneId: restorePaneId, + }, + }, + }; + }); + }, + resizeSplit: (workspaceKey, groupId, sizes) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedGroupId = trimNonEmpty(groupId); + if (!normalizedWorkspaceKey || !normalizedGroupId) { + return; + } + + set((state) => ({ + splitSizesByWorkspace: { + ...state.splitSizesByWorkspace, + [normalizedWorkspaceKey]: { + ...state.splitSizesByWorkspace[normalizedWorkspaceKey], + [normalizedGroupId]: clampNormalizedSizes(sizes), + }, + }, + })); + }, + reorderTabsInPane: (workspaceKey, paneId, tabIds) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedPaneId = trimNonEmpty(paneId); + if (!normalizedWorkspaceKey || !normalizedPaneId) { + return; + } + + set((state) => { + const nextLayout = reorderPaneTabsInLayout({ + layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + paneId: normalizedPaneId, + tabIds, + }); + if (!nextLayout) { + return state; + } + + return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: nextLayout, + }, + }; + }); + }, + pinAgent: (workspaceKey, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedAgentId) { + return; + } + + set((state) => { + const currentPinnedAgentIds = + state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; + if (currentPinnedAgentIds?.has(normalizedAgentId)) { + return state; + } + + const nextPinnedAgentIds = new Set(currentPinnedAgentIds ?? []); + nextPinnedAgentIds.add(normalizedAgentId); + + return { + hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ), + pinnedAgentIdsByWorkspace: { + ...state.pinnedAgentIdsByWorkspace, + [normalizedWorkspaceKey]: nextPinnedAgentIds, + }, + }; + }); + }, + unpinAgent: (workspaceKey, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedAgentId) { + return; + } + + set((state) => { + const currentPinnedAgentIds = + state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; + if (!currentPinnedAgentIds?.has(normalizedAgentId)) { + return state; + } + + if (currentPinnedAgentIds.size === 1) { + const nextPinnedAgentIdsByWorkspace = { + ...state.pinnedAgentIdsByWorkspace, + }; + delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey]; + return { + pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace, + }; + } + + const nextPinnedAgentIds = new Set(currentPinnedAgentIds); + nextPinnedAgentIds.delete(normalizedAgentId); + + return { + pinnedAgentIdsByWorkspace: { + ...state.pinnedAgentIdsByWorkspace, + [normalizedWorkspaceKey]: nextPinnedAgentIds, + }, + }; + }); + }, + hideAgent: (workspaceKey, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedAgentId) { + return; + } + + set((state) => { + const nextHiddenAgentIdsByWorkspace = addAgentIdToWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ); + if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) { + return state; + } + + return { + hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace, + }; + }); + }, + unhideAgent: (workspaceKey, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedAgentId) { + return; + } + + set((state) => { + const nextHiddenAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ); + if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) { + return state; + } + + return { + hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace, + }; + }); + }, + purgeWorkspace: (workspaceKey) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return; + } + + set((state) => { + const hasAny = + normalizedWorkspaceKey in state.layoutByWorkspace || + normalizedWorkspaceKey in state.splitSizesByWorkspace || + normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || + normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || + normalizedWorkspaceKey in state.focusRestorationByWorkspace; + if (!hasAny) { + return state; + } + const { [normalizedWorkspaceKey]: _layout, ...layoutByWorkspace } = + state.layoutByWorkspace; + const { [normalizedWorkspaceKey]: _splits, ...splitSizesByWorkspace } = + state.splitSizesByWorkspace; + const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } = + state.pinnedAgentIdsByWorkspace; + const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } = + state.hiddenAgentIdsByWorkspace; + const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = + state.focusRestorationByWorkspace; + return { + layoutByWorkspace, + splitSizesByWorkspace, + pinnedAgentIdsByWorkspace, + hiddenAgentIdsByWorkspace, + focusRestorationByWorkspace, + }; + }); + }, + }), + { + name: "workspace-layout-state", + version: 1, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => { + const layoutByWorkspace: Record = {}; + for (const key in state.layoutByWorkspace) { + layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]); } - const { [normalizedWorkspaceKey]: _layout, ...layoutByWorkspace } = - state.layoutByWorkspace; - const { [normalizedWorkspaceKey]: _splits, ...splitSizesByWorkspace } = - state.splitSizesByWorkspace; - const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } = - state.pinnedAgentIdsByWorkspace; - const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } = - state.hiddenAgentIdsByWorkspace; - const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = - state.focusRestorationByWorkspace; return { layoutByWorkspace, - splitSizesByWorkspace, - pinnedAgentIdsByWorkspace, - hiddenAgentIdsByWorkspace, - focusRestorationByWorkspace, + splitSizesByWorkspace: state.splitSizesByWorkspace, }; - }); + }, }, - }), - { - name: "workspace-layout-state", - version: 1, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => { - const layoutByWorkspace: Record = {}; - for (const key in state.layoutByWorkspace) { - layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]); - } - return { - layoutByWorkspace, - splitSizesByWorkspace: state.splitSizesByWorkspace, - }; - }, - }, - ), -); + ), + ); +} + +export const useWorkspaceLayoutStore = createWorkspaceLayoutStore(); export function useWorkspaceLayoutStoreHydrated(): boolean { const [hasHydrated, setHasHydrated] = useState(() =>