Keep restored workspaces out of reconciliation loops (#3987)

* fix(app): stop workspace tab reconciliation loop

Duplicate startup tabs could move workspace focus into Explorer. Once hydration removed the stale agent, empty-workspace reconciliation kept seeding drafts into Explorer and retriggering itself. Preserve ordinary-pane focus at the reconciliation boundary.

* fix(app): normalize workspace focus before reconciliation

Persisted Explorer focus must not steer newly reconciled tabs into the sidebar before the output invariant is applied.
This commit is contained in:
Mohamed Boudra
2026-08-28 15:50:44 +02:00
committed by GitHub
parent 42b6d99d60
commit 7ea4288117
2 changed files with 110 additions and 9 deletions
@@ -3391,6 +3391,99 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId);
});
it("reconcileTabs stays stable when startup removes a duplicate agent kept in Explorer", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.78, 0.22],
children: [
createPane({
id: "main",
tabIds: ["new", "draft-origin-agent"],
focusedTabId: "draft-origin-agent",
targetsByTabId: {
new: { kind: "new_tab" },
"draft-origin-agent": { kind: "agent", agentId: "agent-1" },
},
}),
createPane({
id: "explorer",
tabIds: ["files", "changes_tree", "agent_agent-1"],
focusedTabId: "agent_agent-1",
targetsByTabId: {
files: { kind: "files" },
changes_tree: { kind: "changes_tree" },
"agent_agent-1": { kind: "agent", agentId: "agent-1" },
},
}),
],
},
},
focusedPaneId: "main",
},
},
explorerSidebarPaneIdByWorkspace: {
...state.explorerSidebarPaneIdByWorkspace,
[workspaceKey]: "explorer",
},
}));
store.reconcileTabs(workspaceKey, {
agentsHydrated: false,
terminalsHydrated: false,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe(
"main",
);
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
...state.layoutByWorkspace[workspaceKey],
focusedPaneId: "explorer",
},
},
}));
const emptySnapshot = {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
};
store.reconcileTabs(workspaceKey, emptySnapshot);
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(afterThirdReconcile).toBe(afterSecondReconcile);
const mainTabs = collectAllTabs(afterThirdReconcile.root).filter(
(tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === "main",
);
expect(mainTabs).toEqual([
expect.objectContaining({ target: expect.objectContaining({ kind: "draft" }) }),
]);
});
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey();
@@ -1182,9 +1182,15 @@ export function createWorkspaceLayoutStore(
}
set((state) => {
const currentLayout = getWorkspaceLayout(
state.layoutByWorkspace,
normalizedWorkspaceKey,
const rawLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
rawLayout,
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
);
const currentLayout = keepWorkspaceFocusOutOfExplorerSidebar(
rawLayout,
explorerSidebarPaneId,
rawLayout.focusedPaneId,
);
const nextState = reconcileWorkspaceTabs(
{
@@ -1192,21 +1198,23 @@ export function createWorkspaceLayoutStore(
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
explorerSidebarPaneId: resolveExplorerSidebarPaneId(
currentLayout,
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
),
explorerSidebarPaneId,
},
snapshot,
);
if (nextState.layout === currentLayout) {
const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
nextState.layout,
explorerSidebarPaneId,
currentLayout.focusedPaneId,
);
if (nextLayout === rawLayout) {
return state;
}
return {
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextState.layout,
[normalizedWorkspaceKey]: nextLayout,
},
};
});