fix(app): retain workspace explorer pane

Desktop workspaces need a stable hidden explorer pane so setup and pull-request tabs have a consistent destination without changing the active pane. Mobile keeps pull requests in its explorer sidebar.
This commit is contained in:
Mohamed Boudra
2026-08-18 14:36:27 +02:00
parent e2e97e4854
commit ce9026f264
4 changed files with 363 additions and 114 deletions
@@ -53,9 +53,10 @@ import { traceInstant } from "@/performance/native-trace";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { import {
collectAllTabs, collectAllTabs,
createDefaultLayout, createWorkspaceLayoutWithExplorer,
findPaneById, findPaneById,
getFocusedBrowserId, getFocusedBrowserId,
resolveExplorerPaneId,
type WorkspaceLayout, type WorkspaceLayout,
useWorkspaceLayoutStore, useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated, useWorkspaceLayoutStoreHydrated,
@@ -1360,8 +1361,12 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
return `${serverId}:${workspaceId}`; return `${serverId}:${workspaceId}`;
} }
function canObservePullRequest(isRouteFocused: boolean, isGitCheckout: boolean): boolean { function canObservePullRequest(
return isRouteFocused && isGitCheckout; isRouteFocused: boolean,
isGitCheckout: boolean,
isCompact: boolean,
): boolean {
return isRouteFocused && isGitCheckout && !isCompact && supportsDesktopPaneSplits();
} }
interface ToggleWorkspaceExplorerPaneInput { interface ToggleWorkspaceExplorerPaneInput {
@@ -1383,15 +1388,28 @@ function toggleWorkspaceExplorerPane(input: ToggleWorkspaceExplorerPaneInput): v
} }
const store = useWorkspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const layout = store.layoutByWorkspace[input.persistenceKey] ?? createDefaultLayout(); const layout =
store.layoutByWorkspace[input.persistenceKey] ?? createWorkspaceLayoutWithExplorer();
const parentTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null; const parentTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null;
const explorerPaneId = store.explorerPaneIdByWorkspace[input.persistenceKey] ?? null; const explorerPaneId = resolveExplorerPaneId(
layout,
store.explorerPaneIdByWorkspace[input.persistenceKey],
);
const explorerPane = findPaneById(layout.root, explorerPaneId); const explorerPane = findPaneById(layout.root, explorerPaneId);
if (explorerPane) { if (explorerPane) {
if (explorerPane.hidden === true) { if (explorerPane.hidden === true) {
store.showPane(input.persistenceKey, explorerPane.id); store.showPane(input.persistenceKey, explorerPane.id);
} else { } else {
store.hidePane(input.persistenceKey, explorerPane.id); store.hidePane(input.persistenceKey, explorerPane.id);
return;
}
if (explorerPane.tabIds.length === 0) {
const tabId = parentTabId
? store.openChildTabFocused(input.persistenceKey, { kind: "working_diff" }, parentTabId)
: store.openTabFocused(input.persistenceKey, { kind: "working_diff" });
if (tabId) {
store.moveTabToPane(input.persistenceKey, tabId, explorerPane.id);
}
} }
return; return;
} }
@@ -1415,8 +1433,10 @@ function isWorkspaceExplorerPaneOpen(
if (!persistenceKey) { if (!persistenceKey) {
return false; return false;
} }
const paneId = state.explorerPaneIdByWorkspace[persistenceKey];
const layout = state.layoutByWorkspace[persistenceKey]; const layout = state.layoutByWorkspace[persistenceKey];
const paneId = layout
? resolveExplorerPaneId(layout, state.explorerPaneIdByWorkspace[persistenceKey])
: null;
const pane = paneId && layout ? findPaneById(layout.root, paneId) : null; const pane = paneId && layout ? findPaneById(layout.root, paneId) : null;
return Boolean(pane && pane.hidden !== true); return Boolean(pane && pane.hidden !== true);
} }
@@ -1706,7 +1726,7 @@ function WorkspaceScreenContent({
workspaceKey: persistenceKey, workspaceKey: persistenceKey,
serverId: normalizedServerId, serverId: normalizedServerId,
cwd: workspaceDirectory, cwd: workspaceDirectory,
enabled: canObservePullRequest(isRouteFocused, isGitCheckout), enabled: canObservePullRequest(isRouteFocused, isGitCheckout, isMobile),
}); });
const isCompactExplorerOpen = usePanelStore((state) => const isCompactExplorerOpen = usePanelStore((state) =>
@@ -1785,6 +1805,12 @@ function WorkspaceScreenContent({
const openWorkspaceTabInBackground = useWorkspaceLayoutStore( const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
(state) => state.openTabInBackground, (state) => state.openTabInBackground,
); );
const openWorkspaceTabInExplorerPaneBackground = useWorkspaceLayoutStore(
(state) => state.openTabInExplorerPaneBackground,
);
const openWorkspaceTabInExplorerPaneFocused = useWorkspaceLayoutStore(
(state) => state.openTabInExplorerPaneFocused,
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
@@ -1994,7 +2020,6 @@ function WorkspaceScreenContent({
), ),
[normalizedWorkspaceId, uiTabs], [normalizedWorkspaceId, uiTabs],
); );
const navigateToTabId = useCallback( const navigateToTabId = useCallback(
function navigateToTabId(tabId: string) { function navigateToTabId(tabId: string) {
if (!tabId || !persistenceKey) { if (!tabId || !persistenceKey) {
@@ -2020,6 +2045,64 @@ function WorkspaceScreenContent({
const emptyWorkspaceSeedRef = useRef<string | null>(null); const emptyWorkspaceSeedRef = useRef<string | null>(null);
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null); const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
useEffect(() => {
if (!isRouteFocused) {
return;
}
if (!persistenceKey) {
return;
}
if (!workspaceSetupSnapshot || !showWorkspaceSetup) {
if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) {
autoOpenedSetupTabWorkspaceRef.current = null;
}
return;
}
const snapshotAge = Date.now() - workspaceSetupSnapshot.updatedAt;
const shouldAutoOpen =
workspaceSetupSnapshot.status === "running" ||
snapshotAge <= WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS;
if (!shouldAutoOpen) {
return;
}
if (hasSetupTab) {
autoOpenedSetupTabWorkspaceRef.current = persistenceKey;
return;
}
if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) {
return;
}
const target = normalizeWorkspaceTabTarget({
kind: "setup",
workspaceId: normalizedWorkspaceId,
});
if (!target) {
return;
}
const tabId =
isMobile || !supportsDesktopPaneSplits()
? openWorkspaceTabInBackground(persistenceKey, target)
: openWorkspaceTabInExplorerPaneBackground(persistenceKey, target);
if (!tabId) {
return;
}
autoOpenedSetupTabWorkspaceRef.current = persistenceKey;
}, [
hasSetupTab,
isMobile,
isRouteFocused,
normalizedWorkspaceId,
openWorkspaceTabInBackground,
openWorkspaceTabInExplorerPaneBackground,
persistenceKey,
showWorkspaceSetup,
workspaceSetupSnapshot,
]);
useEffect(() => { useEffect(() => {
if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) { if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) {
return; return;
@@ -2075,59 +2158,6 @@ function WorkspaceScreenContent({
workspaceAgentVisibility.activeAgentIds.size, workspaceAgentVisibility.activeAgentIds.size,
]); ]);
useEffect(() => {
if (!isRouteFocused) {
return;
}
if (!persistenceKey) {
return;
}
if (!workspaceSetupSnapshot || !showWorkspaceSetup) {
if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) {
autoOpenedSetupTabWorkspaceRef.current = null;
}
return;
}
const snapshotAge = Date.now() - workspaceSetupSnapshot.updatedAt;
const shouldAutoOpen =
workspaceSetupSnapshot.status === "running" ||
snapshotAge <= WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS;
if (!shouldAutoOpen) {
return;
}
if (hasSetupTab) {
autoOpenedSetupTabWorkspaceRef.current = persistenceKey;
return;
}
if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) {
return;
}
const target = normalizeWorkspaceTabTarget({
kind: "setup",
workspaceId: normalizedWorkspaceId,
});
if (!target) {
return;
}
const tabId = openWorkspaceTabInBackground(persistenceKey, target);
if (!tabId) {
return;
}
autoOpenedSetupTabWorkspaceRef.current = persistenceKey;
}, [
hasSetupTab,
isRouteFocused,
normalizedWorkspaceId,
openWorkspaceTabInBackground,
persistenceKey,
showWorkspaceSetup,
workspaceSetupSnapshot,
]);
const handleOpenFileFromExplorer = useCallback( const handleOpenFileFromExplorer = useCallback(
function handleOpenFileFromExplorer(filePath: string) { function handleOpenFileFromExplorer(filePath: string) {
if (!persistenceKey) { if (!persistenceKey) {
@@ -2704,8 +2734,18 @@ function WorkspaceScreenContent({
if (!target) { if (!target) {
return; return;
} }
openWorkspaceTabFocused(persistenceKey, target); if (isMobile || !supportsDesktopPaneSplits()) {
}, [normalizedWorkspaceId, openWorkspaceTabFocused, persistenceKey]); openWorkspaceTabFocused(persistenceKey, target);
return;
}
openWorkspaceTabInExplorerPaneFocused(persistenceKey, { target });
}, [
isMobile,
normalizedWorkspaceId,
openWorkspaceTabFocused,
openWorkspaceTabInExplorerPaneFocused,
persistenceKey,
]);
const handleBulkCloseTabs = useCallback( const handleBulkCloseTabs = useCallback(
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => { async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
@@ -140,6 +140,7 @@ interface ReorderFocusedPaneTabsInLayoutInput {
interface CloseTabInLayoutInput { interface CloseTabInLayoutInput {
layout: WorkspaceLayout; layout: WorkspaceLayout;
tabId: string; tabId: string;
preserveEmptyPaneId?: string | null;
} }
interface SplitPaneInLayoutInput { interface SplitPaneInLayoutInput {
@@ -211,6 +212,8 @@ export interface WorkspaceTabSnapshot {
} }
const DEFAULT_PANE_ID = "main"; const DEFAULT_PANE_ID = "main";
export const DEFAULT_EXPLORER_PANE_ID = "explorer";
const DEFAULT_LAYOUT_GROUP_ID = "workspace-root";
function trimNonEmpty(value: string | null | undefined): string | null { function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -1051,6 +1054,22 @@ export function createDefaultLayout(): WorkspaceLayout {
}; };
} }
/** The desktop companion pane exists before it is first shown. */
export function createWorkspaceLayoutWithExplorer(): WorkspaceLayout {
return {
root: createGroupNode({
id: DEFAULT_LAYOUT_GROUP_ID,
direction: "horizontal",
children: [
createPaneNode({ id: DEFAULT_PANE_ID }),
createPaneNode({ id: DEFAULT_EXPLORER_PANE_ID, hidden: true }),
],
sizes: [0.5, 0.5],
}),
focusedPaneId: DEFAULT_PANE_ID,
};
}
export function insertSplit( export function insertSplit(
root: SplitNode, root: SplitNode,
targetPaneId: string, targetPaneId: string,
@@ -1079,7 +1098,10 @@ export function removePaneFromTree(root: SplitNode, paneId: string): SplitNode {
} }
export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode { export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
return detachTabFromTree(asInternalNode(root), { tabId }).root; return detachTabFromTree(asInternalNode(root), {
tabId,
preserveEmptyPaneId: DEFAULT_PANE_ID,
}).root;
} }
function insertNewTabIntoFocusedPane(input: { function insertNewTabIntoFocusedPane(input: {
@@ -1182,6 +1204,9 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
if (!pane) { if (!pane) {
return null; return null;
} }
const preserveEmptyPaneId =
input.preserveEmptyPaneId ??
(pane.id === DEFAULT_PANE_ID || pane.id === DEFAULT_EXPLORER_PANE_ID ? pane.id : null);
const closeSuccessorTabId = getCloseSuccessorTabId({ const closeSuccessorTabId = getCloseSuccessorTabId({
pane, pane,
@@ -1190,7 +1215,10 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
parentTabIdByTabId: input.layout.parentTabIdByTabId, parentTabIdByTabId: input.layout.parentTabIdByTabId,
}); });
const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id); const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id);
const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal; const nextRoot = detachTabFromTree(internalLayout.root, {
tabId: input.tabId,
preserveEmptyPaneId,
}).root;
const parentTabIdByTabId = normalizeParentTabMap({ const parentTabIdByTabId = normalizeParentTabMap({
raw: input.layout.parentTabIdByTabId, raw: input.layout.parentTabIdByTabId,
openTabIds: new Set(collectAllTabs(nextRoot).map((tab) => tab.tabId)), openTabIds: new Set(collectAllTabs(nextRoot).map((tab) => tab.tabId)),
@@ -22,6 +22,7 @@ import {
collectAllTabs, collectAllTabs,
createWorkspaceLayoutStore, createWorkspaceLayoutStore,
createDefaultLayout, createDefaultLayout,
createWorkspaceLayoutWithExplorer,
findPaneById, findPaneById,
findPaneContainingTab, findPaneContainingTab,
getFocusedBrowserId, getFocusedBrowserId,
@@ -329,7 +330,7 @@ describe("workspace-layout-store actions", () => {
}); });
}); });
it("keeps layouts written before split sizes were persisted", async () => { it("migrates legacy layouts to include the hidden registered explorer pane", async () => {
const legacyLayout = createDefaultLayout(); const legacyLayout = createDefaultLayout();
await AsyncStorage.setItem( await AsyncStorage.setItem(
"workspace-layout-state", "workspace-layout-state",
@@ -342,7 +343,12 @@ describe("workspace-layout-store actions", () => {
await restored.persist.rehydrate(); await restored.persist.rehydrate();
expect(restored.getState().layoutByWorkspace.legacy).toEqual(legacyLayout); const state = restored.getState();
const layout = state.layoutByWorkspace.legacy;
const explorerPaneId = state.explorerPaneIdByWorkspace.legacy;
expect(findPaneById(layout.root, "main")).toBeTruthy();
expect(explorerPaneId).toBeTruthy();
expect(findPaneById(layout.root, explorerPaneId)?.hidden).toBe(true);
expect(restored.getState().splitSizesByWorkspace).toEqual({}); expect(restored.getState().splitSizesByWorkspace).toEqual({});
await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull(); await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull();
}); });
@@ -407,6 +413,40 @@ describe("workspace-layout-store actions", () => {
]); ]);
}); });
it("reveals the registered explorer pane for a first detected pull request", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1");
const state = workspaceLayoutStore.getState();
const layout = state.layoutByWorkspace[workspaceKey];
const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey];
expect(explorerPaneId).toBe("explorer");
expect(findPaneById(layout.root, explorerPaneId)?.hidden).toBeUndefined();
expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe(explorerPaneId);
});
it("creates setup in the hidden explorer pane without changing focus", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const tabId = store.openTabInExplorerPaneBackground(workspaceKey, {
kind: "setup",
workspaceId: WORKSPACE_ID,
});
const state = workspaceLayoutStore.getState();
const layout = state.layoutByWorkspace[workspaceKey];
const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey];
expect(tabId).toBe("setup_ws-main");
expect(layout.focusedPaneId).toBe("main");
expect(findPaneById(layout.root, explorerPaneId)?.hidden).toBe(true);
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(agentTabId);
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPaneId);
});
it("places an auto-added pull request in the registered explorer pane", () => { it("places an auto-added pull request in the registered explorer pane", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = workspaceLayoutStore.getState();
@@ -445,8 +485,6 @@ describe("workspace-layout-store actions", () => {
it("opens assistant files in an ensured explorer pane and reveals it on the next open", () => { it("opens assistant files in an ensured explorer pane and reveals it on the next open", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = workspaceLayoutStore.getState();
useWorkspaceLayoutIds("explorer");
const tabId = store.openTabInExplorerPaneFocused(workspaceKey, { const tabId = store.openTabInExplorerPaneFocused(workspaceKey, {
target: { kind: "file", path: "/repo/worktree/a.ts" }, target: { kind: "file", path: "/repo/worktree/a.ts" },
parentTabId: "agent_agent-a", parentTabId: "agent_agent-a",
@@ -454,7 +492,7 @@ describe("workspace-layout-store actions", () => {
const createdState = workspaceLayoutStore.getState(); const createdState = workspaceLayoutStore.getState();
const explorerPaneId = createdState.explorerPaneIdByWorkspace[workspaceKey]; const explorerPaneId = createdState.explorerPaneIdByWorkspace[workspaceKey];
expect(explorerPaneId).toBe("pane_explorer"); expect(explorerPaneId).toBe("explorer");
expect( expect(
findPaneContainingTab(createdState.layoutByWorkspace[workspaceKey].root, tabId!)?.id, findPaneContainingTab(createdState.layoutByWorkspace[workspaceKey].root, tabId!)?.id,
).toBe(explorerPaneId); ).toBe(explorerPaneId);
@@ -1276,7 +1314,7 @@ describe("workspace-layout-store actions", () => {
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
}); });
it("splitPane enforces the maximum depth of four", () => { it("splitPane preserves four user-created levels beneath the explorer split", () => {
useWorkspaceLayoutIds( useWorkspaceLayoutIds(
"11111111-1111-1111-1111-111111111111", "11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222", "22222222-2222-2222-2222-222222222222",
@@ -1323,7 +1361,7 @@ describe("workspace-layout-store actions", () => {
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333"); expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555"); expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
expect(pane4).toBeNull(); expect(pane4).toBeNull();
expect(getTreeDepth(layout.root)).toBe(4); expect(getTreeDepth(layout.root)).toBe(5);
}); });
it("moveTabToPane collapses the source pane when its last tab moves out", () => { it("moveTabToPane collapses the source pane when its last tab moves out", () => {
@@ -1388,38 +1426,13 @@ describe("workspace-layout-store actions", () => {
store.closeTab(workspaceKey, secondTabId!); store.closeTab(workspaceKey, secondTabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const rootGroup = expectGroup(layout.root);
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878"); expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
expect(paneCId).toBe("pane_9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a"); expect(paneCId).toBe("pane_9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
expect(layout.focusedPaneId).toBe(paneCId); expect(layout.focusedPaneId).toBe(paneCId);
expect(rootGroup.group.direction).toBe("horizontal"); expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]);
expect(rootGroup.group.children).toHaveLength(2); expect(findPaneById(layout.root, paneCId)?.tabIds).toEqual(["file_/repo/worktree/c.ts"]);
expect( expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true);
rootGroup.group.children.map((child) => {
expect(child.kind).toBe("pane");
if (child.kind !== "pane") {
throw new Error("Expected pane child");
}
return {
id: child.pane.id,
tabIds: child.pane.tabIds,
focusedTabId: child.pane.focusedTabId,
};
}),
).toEqual([
{
id: "main",
tabIds: ["file_/repo/worktree/a.ts"],
focusedTabId: "file_/repo/worktree/a.ts",
},
{
id: paneCId!,
tabIds: ["file_/repo/worktree/c.ts"],
focusedTabId: "file_/repo/worktree/c.ts",
},
]);
expect(rootGroup.group.sizes).toEqual([0.5, 0.5]);
}); });
it("openTab focuses the existing tab instead of creating a duplicate entry", () => { it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
@@ -1546,17 +1559,21 @@ describe("workspace-layout-store actions", () => {
const farRightPaneId = store.splitPane(workspaceKey, { const farRightPaneId = store.splitPane(workspaceKey, {
tabId: c!, tabId: c!,
targetPaneId: rightPaneId!, targetPaneId: rightPaneId!,
position: "right", position: "bottom",
}); });
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const splitGroup = expectGroup(splitRoot); const splitGroup = expectGroup(splitRoot);
const nestedGroup = expectGroup(splitGroup.group.children[1]); const nestedGroup = expectGroup(
splitGroup.group.children.find((child) => child.kind === "group")!,
);
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]); store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const resizedGroup = expectGroup(resizedRoot); const resizedGroup = expectGroup(resizedRoot);
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]); const resizedNestedGroup = expectGroup(
resizedGroup.group.children.find((child) => child.kind === "group")!,
);
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0); const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
@@ -1574,7 +1591,7 @@ describe("workspace-layout-store actions", () => {
store.closeTab(workspaceKey, tabId!); store.closeTab(workspaceKey, tabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout).toEqual(createDefaultLayout()); expect(layout).toEqual(createWorkspaceLayoutWithExplorer());
}); });
it("keeps pinned archived agents in memory per workspace without persisting them", () => { it("keeps pinned archived agents in memory per workspace without persisting them", () => {
+174 -10
View File
@@ -15,6 +15,8 @@ import {
collectAllTabs, collectAllTabs,
convertDraftToAgentInLayout, convertDraftToAgentInLayout,
createDefaultLayout, createDefaultLayout,
createWorkspaceLayoutWithExplorer,
DEFAULT_EXPLORER_PANE_ID,
findPaneById, findPaneById,
findPaneContainingTab, findPaneContainingTab,
focusPaneInLayout, focusPaneInLayout,
@@ -50,6 +52,7 @@ export {
collectAllPanes, collectAllPanes,
collectAllTabs, collectAllTabs,
createDefaultLayout, createDefaultLayout,
createWorkspaceLayoutWithExplorer,
findPaneById, findPaneById,
findPaneContainingTab, findPaneContainingTab,
getFocusedBrowserId, getFocusedBrowserId,
@@ -85,6 +88,10 @@ interface WorkspaceLayoutStore {
parentTabId: string, parentTabId: string,
) => string | null; ) => string | null;
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
openTabInExplorerPaneBackground: (
workspaceKey: string,
target: WorkspaceTabTarget,
) => string | null;
ensureExplorerPane: (workspaceKey: string) => EnsureExplorerPaneResult | null; ensureExplorerPane: (workspaceKey: string) => EnsureExplorerPaneResult | null;
openTabInExplorerPaneFocused: ( openTabInExplorerPaneFocused: (
workspaceKey: string, workspaceKey: string,
@@ -140,7 +147,9 @@ interface WorkspaceFocusRestorationState {
tokens: string[]; tokens: string[];
} }
const MAX_TREE_DEPTH = 4; // The root companion split is always present for the hidden explorer pane.
// Preserve four user-created split levels beneath it.
const MAX_TREE_DEPTH = 5;
const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
provider: z.string(), provider: z.string(),
@@ -293,7 +302,52 @@ function getWorkspaceLayout(
state: Record<string, WorkspaceLayout>, state: Record<string, WorkspaceLayout>,
workspaceKey: string, workspaceKey: string,
): WorkspaceLayout { ): WorkspaceLayout {
return normalizeLayout(state[workspaceKey] ?? createDefaultLayout()); return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithExplorer());
}
export function resolveExplorerPaneId(
layout: WorkspaceLayout,
registeredPaneId: string | null | undefined,
): string | null {
const registeredPane = findPaneById(layout.root, registeredPaneId ?? null);
if (registeredPane) {
return registeredPane.id;
}
const defaultPane = findPaneById(layout.root, DEFAULT_EXPLORER_PANE_ID);
return defaultPane?.id ?? null;
}
function ensurePersistedExplorerPane(input: {
layout: WorkspaceLayout;
registeredPaneId: string | null | undefined;
ids: WorkspaceLayoutIdSource;
}): { layout: WorkspaceLayout; paneId: string } | null {
const existingPaneId = resolveExplorerPaneId(input.layout, input.registeredPaneId);
if (existingPaneId) {
return { layout: input.layout, paneId: existingPaneId };
}
const targetPaneId =
findPaneById(input.layout.root, input.layout.focusedPaneId)?.id ??
collectAllPanes(input.layout.root)[0]?.id;
if (!targetPaneId) {
return null;
}
const split = splitPaneEmptyInLayout({
layout: input.layout,
targetPaneId,
position: "right",
createNodeId: input.ids.createNodeId,
maxTreeDepth: MAX_TREE_DEPTH,
});
if (!split) {
return null;
}
const hiddenLayout = setPaneHiddenInLayout({
layout: split.layout,
paneId: split.paneId,
hidden: true,
});
return { layout: hiddenLayout ?? split.layout, paneId: split.paneId };
} }
function withoutFocusRestoration( function withoutFocusRestoration(
@@ -447,6 +501,71 @@ export function createWorkspaceLayoutStore(
return result.tabId; return result.tabId;
}, },
openTabInExplorerPaneBackground: (workspaceKey, target) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedTarget) {
return null;
}
let tabId: string | null = null;
set((state) => {
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerPaneId = resolveExplorerPaneId(
layout,
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
);
const explorerPane = findPaneById(layout.root, explorerPaneId);
if (!explorerPane) {
return state;
}
const originalFocusedPaneId = layout.focusedPaneId;
const revealedLayout = focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout;
const result = openTabInLayoutBackground({
layout: revealedLayout,
target: normalizedTarget,
now: Date.now(),
});
const currentPane = findPaneContainingTab(result.layout.root, result.tabId);
const placedLayout =
currentPane?.id === explorerPane.id
? result.layout
: (moveTabToPaneInLayout({
layout: result.layout,
tabId: result.tabId,
toPaneId: explorerPane.id,
}) ?? result.layout);
const restoredVisibility = explorerPane.hidden
? (setPaneHiddenInLayout({
layout: placedLayout,
paneId: explorerPane.id,
hidden: true,
}) ?? placedLayout)
: placedLayout;
const restoredLayout =
originalFocusedPaneId && originalFocusedPaneId !== explorerPane.id
? (focusPaneInLayout({
layout: restoredVisibility,
paneId: originalFocusedPaneId,
}) ?? restoredVisibility)
: restoredVisibility;
tabId = result.tabId;
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
explorerPaneIdByWorkspace: {
...state.explorerPaneIdByWorkspace,
[normalizedWorkspaceKey]: explorerPane.id,
},
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: restoredLayout,
},
};
});
return tabId;
},
ensureExplorerPane: (workspaceKey) => { ensureExplorerPane: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) { if (!normalizedWorkspaceKey) {
@@ -455,9 +574,15 @@ export function createWorkspaceLayoutStore(
const store = get(); const store = get();
const layout = getWorkspaceLayout(store.layoutByWorkspace, normalizedWorkspaceKey); const layout = getWorkspaceLayout(store.layoutByWorkspace, normalizedWorkspaceKey);
const explorerPaneId = store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null; const explorerPaneId = resolveExplorerPaneId(
layout,
store.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
);
const explorerPane = findPaneById(layout.root, explorerPaneId); const explorerPane = findPaneById(layout.root, explorerPaneId);
if (explorerPane) { if (explorerPane) {
if (store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] !== explorerPane.id) {
store.setExplorerPaneId(normalizedWorkspaceKey, explorerPane.id);
}
store.focusPane(normalizedWorkspaceKey, explorerPane.id); store.focusPane(normalizedWorkspaceKey, explorerPane.id);
return { paneId: explorerPane.id, created: false }; return { paneId: explorerPane.id, created: false };
} }
@@ -521,11 +646,16 @@ export function createWorkspaceLayoutStore(
} }
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerPaneId = state.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null; const explorerPaneId = resolveExplorerPaneId(
layout,
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
);
const explorerPane = findPaneById(layout.root, explorerPaneId); const explorerPane = findPaneById(layout.root, explorerPaneId);
const placementLayout = explorerPane if (!explorerPane) {
? (focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout) return state;
: layout; }
const placementLayout =
focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout;
const result = openTabInLayoutFocused({ const result = openTabInLayoutFocused({
layout: placementLayout, layout: placementLayout,
target: { kind: "pull_request" }, target: { kind: "pull_request" },
@@ -538,6 +668,10 @@ export function createWorkspaceLayoutStore(
...state.acknowledgedPullRequestByWorkspace, ...state.acknowledgedPullRequestByWorkspace,
[normalizedWorkspaceKey]: normalizedIdentity, [normalizedWorkspaceKey]: normalizedIdentity,
}, },
explorerPaneIdByWorkspace: {
...state.explorerPaneIdByWorkspace,
[normalizedWorkspaceKey]: explorerPane.id,
},
layoutByWorkspace: { layoutByWorkspace: {
...state.layoutByWorkspace, ...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout, [normalizedWorkspaceKey]: result.layout,
@@ -553,9 +687,20 @@ export function createWorkspaceLayoutStore(
} }
set((state) => { set((state) => {
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerPaneId = resolveExplorerPaneId(
layout,
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
);
const closingPane = findPaneContainingTab(layout.root, normalizedTabId);
const preserveEmptyPaneId =
closingPane?.id === "main" || closingPane?.id === explorerPaneId
? closingPane.id
: null;
const nextLayout = closeTabInLayout({ const nextLayout = closeTabInLayout({
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), layout,
tabId: normalizedTabId, tabId: normalizedTabId,
preserveEmptyPaneId,
}); });
if (!nextLayout) { if (!nextLayout) {
return state; return state;
@@ -1252,11 +1397,30 @@ export function createWorkspaceLayoutStore(
if (!result.success) { if (!result.success) {
return currentState; return currentState;
} }
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
const explorerPaneIdByWorkspace: Record<string, string | null> = {
...result.data.explorerPaneIdByWorkspace,
};
for (const [workspaceKey, persistedLayout] of Object.entries(
result.data.layoutByWorkspace,
)) {
const explorer = ensurePersistedExplorerPane({
layout: normalizeLayout(persistedLayout),
registeredPaneId: explorerPaneIdByWorkspace[workspaceKey],
ids,
});
if (!explorer) {
layoutByWorkspace[workspaceKey] = normalizeLayout(persistedLayout);
continue;
}
layoutByWorkspace[workspaceKey] = explorer.layout;
explorerPaneIdByWorkspace[workspaceKey] = explorer.paneId;
}
return { return {
...currentState, ...currentState,
layoutByWorkspace: result.data.layoutByWorkspace, layoutByWorkspace,
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
explorerPaneIdByWorkspace: result.data.explorerPaneIdByWorkspace ?? {}, explorerPaneIdByWorkspace,
acknowledgedPullRequestByWorkspace: acknowledgedPullRequestByWorkspace:
result.data.acknowledgedPullRequestByWorkspace ?? {}, result.data.acknowledgedPullRequestByWorkspace ?? {},
}; };