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.
This commit is contained in:
Mohamed Boudra
2026-10-22 11:45:31 +07:00
committed by GitHub
parent bad132884d
commit bdae614e95
3 changed files with 114 additions and 2 deletions
@@ -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);
});
});
@@ -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,
@@ -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<string, string>();
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"]);
},
);