From bdae614e95d7501f685cef2e65d1da864978d363 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 11:45:31 +0700 Subject: [PATCH] fix(app): restore chats after reopening unread workspaces (#7608) Parked pane focus was persisted without its in-memory restoration record, so cold starts exposed hidden Explorer tabs and never requested the agent timeline. Restore an ordinary pane at the layout boundary, including for existing saved layouts. --- .../sidebar-workspace-mark-unread.spec.ts | 43 ++++++++++++ .../src/stores/workspace-layout-actions.ts | 8 ++- .../workspace-layout-persistence.test.ts | 65 ++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts index bc3d4bbf95..23127bbdd9 100644 --- a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -6,6 +6,7 @@ import { seedMockAgentWorkspace, type MockAgentWorkspace, } from "../support/helpers/mock-agent"; +import { expectOpenTabCount } from "../support/helpers/sheet"; import { getServerId } from "../support/helpers/server-id"; import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar"; @@ -227,3 +228,45 @@ test("manual unread survives leaving the current workspace on compact layout", a await markUnreadThenResumeChat(page, workspaces.subject.workspaceId); await completeTurnAndLeaveOnCompact(page, workspaces); }); + +async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) { + const route = page.url(); + // End this app session before the turn, so its cached transcript cannot contain the new prompt. + await page.goto("about:blank"); + await workspace.client.sendAgentMessage( + workspace.agentId, + "A turn completed while the app was closed.", + ); + await workspace.client.waitForFinish(workspace.agentId, 20_000); + // Reopen the ordinary workspace route, without an agent open intent that would repair focus. + await page.goto(route); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toBeVisible(); +} + +async function expectRestoredCompactChat(page: Page) { + await expectOpenTabCount(page, 1); + await expect( + page.getByText("A turn completed while the app was closed.", { exact: true }), + ).toBeVisible(); +} + +test("reopening a marked unread workspace restores only its agent tab and current history on compact layout", async ({ + page, + workspaces, +}) => { + test.setTimeout(120_000); + await test.step("open a finished chat with one main tab", async () => { + await openCompactWorkspace(page, workspaces.subject); + await closeMobileAgentSidebar(page); + await expectOpenTabCount(page, 1); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); + }); + await test.step("mark the current workspace unread, then close and reopen the app", async () => { + await openMobileAgentSidebar(page); + await markAsUnread(page, workspaces.subject.workspaceId); + await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject); + }); + await test.step("restore the main tab without Explorer entries and load its latest history", async () => { + await expectRestoredCompactChat(page); + }); +}); diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 5e87c2eb65..79e0b4fc76 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -1192,7 +1192,10 @@ export function restoreWorkspaceLayout( const normalized = normalizeLayout(layout); let root = asInternalNode(normalized.root); let focusedPaneId = normalized.focusedPaneId; - if (!collectAllPanes(root).some((pane) => pane.id !== explorerSidebarPaneId)) { + const visibleOrdinaryPane = collectAllPanes(root).find( + (pane) => pane.id !== explorerSidebarPaneId, + ); + if (!visibleOrdinaryPane) { const ordinaryPane = findOrdinaryPane(root, explorerSidebarPaneId); if (ordinaryPane) { root = updatePaneInTree(root, { @@ -1214,6 +1217,9 @@ export function restoreWorkspaceLayout( sizes: [0.78, 0.22], }); } + } else if (focusedPaneId === null) { + // Parked focus has an in-memory restoration record that cannot survive reload. + focusedPaneId = visibleOrdinaryPane.id; } return { ...normalized, diff --git a/packages/app/src/stores/workspace-layout-persistence.test.ts b/packages/app/src/stores/workspace-layout-persistence.test.ts index 3a54204598..3398199ae4 100644 --- a/packages/app/src/stores/workspace-layout-persistence.test.ts +++ b/packages/app/src/stores/workspace-layout-persistence.test.ts @@ -1,7 +1,13 @@ import { indexedDB, IDBKeyRange } from "fake-indexeddb"; import { expect, it } from "vitest"; import type { StateStorage } from "zustand/middleware"; -import { createWorkspaceLayoutStore } from "./workspace-layout-store"; +import { + createWorkspaceLayoutStore, + collectAllTabs, + selectExplorerSidebarPaneId, +} from "./workspace-layout-store"; +import { deriveWorkspacePaneState } from "../screens/workspace/workspace-pane-state"; +import { selectVisibleAgentIds } from "../screens/workspace/visible-agent-ids"; Object.assign(globalThis, { indexedDB, IDBKeyRange }); @@ -88,3 +94,60 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra saved.getState().layoutByWorkspace[workspaceKey], ); }); + +it.each([1, 2])( + "restores the agent pane after parked focus in storage version %i", + async (version) => { + const values = new Map(); + const storage: StateStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + }; + const key = "host:workspace"; + const source = createWorkspaceLayoutStore(undefined, storage); + await source.persist.rehydrate(); + source.getState().setLayoutMode("compact"); + source.getState().openTab({ + workspaceKey: key, + target: { kind: "agent", agentId: "agent-a" }, + intent: "reveal", + }); + source.getState().unfocusPane(key); + expect(source.getState().layoutByWorkspace[key].focusedPaneId).toBeNull(); + const persisted = JSON.parse(values.get("workspace-layout-state")!); + persisted.version = version; + values.set("workspace-layout-state", JSON.stringify(persisted)); + const restored = createWorkspaceLayoutStore(undefined, storage); + restored.getState().setLayoutMode("compact"); + await restored.persist.rehydrate(); + restored.getState().reconcileTabs(key, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-a"], + autoOpenAgentIds: ["agent-a"], + knownTerminalIds: [], + standaloneTerminalIds: [], + }); + const layout = restored.getState().layoutByWorkspace[key]; + const tabs = collectAllTabs(layout.root); + const restoredExplorerId = selectExplorerSidebarPaneId(restored.getState(), key); + const main = deriveWorkspacePaneState({ layout, tabs }); + const visible = selectVisibleAgentIds({ + layout, + tabs, + routeFocused: true, + focusedPaneOnly: true, + explorerPaneId: restoredExplorerId, + explorerVisible: false, + }); + expect(layout.focusedPaneId).toBe("main"); + expect(main.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-a" }); + expect(main.tabs.map((t) => t.descriptor.kind)).toEqual(["agent"]); + expect(visible).toEqual(["agent-a"]); + }, +);