From b21c004ffe05b3ccf30d6c755a6e0f51b0ffcc2a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 15 Sep 2026 04:30:25 +0200 Subject: [PATCH] Keep open chats current and show reconnection progress (#4863) * fix(app): keep open chats current and show resume progress Keep open-chat delivery independent of mounted views, freeze hidden chat rendering, and catch up visible chats before hidden ones. Distinguish host reconnection from timeline freshness so cached messages do not silently appear current after resume. * fix(app): prevent sync status from overlapping action errors Share the existing panel toast slot so action errors remain readable during reconnect and catch-up. Verify the interaction and draft retention in the real browser, preserve the tested freeze transition, and refresh the Nix dependency hash for react-freeze. * test(hub): expose complete creation and message retry failures Partial response assertions hid the creation error and allowed duplicate responses with incomplete agent data. Check complete success envelopes, the shared agent snapshot, and null message errors so future failures print the actual error payload. * fix(app): dismiss retained chat menus before freezing An async fork navigates before its closing state commits, so freezing the source chat could leave its portal backdrop blocking the destination. Remove the shared menu surface in the inactive commit before suspension. Keep regression assertions aligned with open-chat subscriptions, per-agent incremental recovery, plugin contribution lifetimes, and rendered image completion. * test(app): expect the reloaded plugin to contribute its pill again A full reload discards the pressed pill with the page, then evaluates the plugin again and its agents snapshot re-adds the pill. The old count-zero assertion only passed on main because the chat was not ready yet when the URL settled; open chats now subscribe from session start, so the pill is rendered by then. * fix(app): suspend hidden chats on web only react-freeze is Suspense with a never-resolving thenable. Suspending a committed subtree hides its host nodes, tears down layout effects, and detaches refs, which changes Fabric ownership of the retained chat. Split the boundary by platform: the web file keeps the two-commit freeze, the native file keeps the chat mounted and live, and the retained-panel signal remains the only thing that stops background work. Restore the mobile-panels rule the branch had rewritten. * fix(creation): never read a receipt while replacing it Admission reads the creation receipt while the runner replaces it atomically. On Windows a rename over a file this process still has open for reading fails with EPERM, and that error marked an otherwise completed creation as failed (#4865). Queue reads and writes per receipt so one owner never overlaps them, and serialize the record when the write is queued because the runner mutates it afterwards. No retry on EPERM: the conflict is removed rather than tolerated. Linux cannot reproduce the rename failure, so the Windows server-tests job (creation/index.test.ts and hub/execution-session.websocket.test.ts) is the verification for this change. --- docs/coding-standards.md | 2 +- docs/menus.md | 4 + docs/mobile-panels.md | 2 + docs/timeline-sync.md | 31 ++- package-lock.json | 1 + .../browser/agent-message-submission.spec.ts | 14 +- .../e2e/browser/assistant-fork-menu.spec.ts | 1 + .../browser/plugin-workspace-panels.spec.ts | 4 +- .../browser/viewed-agent-timelines.spec.ts | 126 +++++++++- .../e2e/support/helpers/assistant-images.ts | 10 +- .../support/helpers/daemon-websocket-gate.ts | 7 +- .../e2e/support/helpers/relay-deployment.ts | 2 +- .../e2e/support/helpers/timeline-resume.ts | 23 +- .../app/e2e/support/helpers/workspace-ui.ts | 7 +- packages/app/package.json | 1 + .../src/components/ui/menu/menu-surface.tsx | 5 + packages/app/src/contexts/session-context.tsx | 19 +- .../session-resume-revalidation.test.ts | 33 --- .../contexts/session-resume-revalidation.ts | 14 -- .../use-agent-screen-state-machine.test.ts | 10 +- .../hooks/use-agent-screen-state-machine.ts | 6 +- packages/app/src/i18n/resources/ar.ts | 3 +- packages/app/src/i18n/resources/en.ts | 3 +- packages/app/src/i18n/resources/es.ts | 3 +- packages/app/src/i18n/resources/fr.ts | 3 +- packages/app/src/i18n/resources/ja.ts | 3 +- packages/app/src/i18n/resources/ko.ts | 3 +- packages/app/src/i18n/resources/pt-BR.ts | 3 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 3 +- packages/app/src/panels/agent-panel.tsx | 120 ++------- .../src/panels/reconnect-toast-state.test.ts | 17 -- .../app/src/panels/reconnect-toast-state.ts | 18 -- .../app/src/panels/retained-chat-content.tsx | 11 + .../src/panels/retained-chat-content.web.tsx | 17 ++ packages/app/src/runtime/host-runtime.test.ts | 6 +- packages/app/src/runtime/host-runtime.ts | 10 +- .../src/stores/workspace-layout-store.test.ts | 34 +++ .../app/src/stores/workspace-layout-store.ts | 28 +++ packages/app/src/timeline/sync-status.tsx | 49 ++++ .../src/timeline/viewed-timeline-sync.test.ts | 228 +++++++++++++----- .../app/src/timeline/viewed-timeline-sync.ts | 98 ++++---- packages/client/src/daemon-client.test.ts | 91 ++++++- packages/client/src/daemon-client.ts | 32 ++- packages/server/src/server/creation/index.ts | 32 ++- .../hub/execution-session.websocket.test.ts | 37 ++- 46 files changed, 787 insertions(+), 390 deletions(-) delete mode 100644 packages/app/src/contexts/session-resume-revalidation.test.ts delete mode 100644 packages/app/src/contexts/session-resume-revalidation.ts delete mode 100644 packages/app/src/panels/reconnect-toast-state.test.ts delete mode 100644 packages/app/src/panels/reconnect-toast-state.ts create mode 100644 packages/app/src/panels/retained-chat-content.tsx create mode 100644 packages/app/src/panels/retained-chat-content.web.tsx create mode 100644 packages/app/src/timeline/sync-status.tsx diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 9d932b32dc..27f9585236 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -92,7 +92,7 @@ For testing rules, see [testing.md](testing.md). - Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects. - Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update. - Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed. -- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work. +- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering. - Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second. - Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary. - Use stable ids for `key`, never array index for reorderable/filterable lists. diff --git a/docs/menus.md b/docs/menus.md index 3ac9b9fb60..4a8da1c202 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -187,6 +187,10 @@ its own. [floating-panels.md](floating-panels.md). - **One overlay per menu.** Submenus render inside their parent's layer and paint no second backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes. +- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes + inactive. An async action can navigate before its menu closes; a hidden panel must not leave a + portal backdrop blocking the destination. On web the chat suspends one commit after it goes + inactive, and that inactive commit is where the surface unmounts. - Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix positioning bugs there, not at a call site. - Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates, diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 2e6e2f1605..4270ddb108 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -116,6 +116,8 @@ definition, no longer eligible to begin. - Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors, and use the retained-panel active signal to stop timers, polling, and other genuine background work. + Hidden chat content is suspended on web only, through `retained-chat-content.web.tsx`, where DOM + nodes carry no Fabric ownership; the native file keeps the chat live. ## Tests diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 740a040d90..c078f96f8a 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -76,11 +76,20 @@ cannot be reconstructed from sequence metadata. Forking the current context rema ## Resume behavior -Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail -page. -Focus alone does not mutate timeline state; the tail response is compared with the local +Opening, reconnecting, and returning from app background establish the current timeline through +bounded catch-up. Switching between continuously subscribed open chats needs no fetch. +Focus alone does not mutate timeline state; the response is compared with the local authoritative range first. +Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is +offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot +certify that the displayed conversation is current. The timeline owner publishes freshness; the +view renders it without a toast timer or a separate resume workflow. + +Foregrounding probes a nominally connected session immediately. A healthy response preserves the +socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat +or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it. + - The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport. - When the page overlaps or is adjacent to the local end cursor, only projected items newer than @@ -157,14 +166,14 @@ replica cache. The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`: -- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden - agents, up to five subscribed agents. Visible agents always win: if more than five are visible, - they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve - this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up. - Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden - hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting - an evicted retained timeline displays its cached state immediately while authoritative catch-up - advances it to the current tail. +- Selective daemons receive every open workspace chat plus any visible agent pane. Workspace layout + owns open-chat lifetime, independently of mounted or retained React views. Switching workspaces, + evicting a retained view, and app backgrounding preserve that demand; closing the chat releases it. + Reconnect restores the open set and gives visible chats the first catch-up attempt. Hidden chats + follow when those attempts settle, including failures, so a failed visible chat does not starve + background recovery. Split panes catch up together. Hidden chats update the replica; on web their + retained presentation stays suspended until revealed, on native it keeps rendering. Revealing a + chat reads the current store and preserves its local UI state. There is no recent-agent limit. - Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing authoritative catch-up, but the app does not issue selective-subscription RPCs. diff --git a/package-lock.json b/package-lock.json index c5a8d95019..a32cf6c543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36274,6 +36274,7 @@ "qrcode": "^1.5.4", "react": "19.1.0", "react-dom": "19.1.0", + "react-freeze": "1.0.4", "react-i18next": "^17.0.8", "react-native": "0.81.5", "react-native-draggable-flatlist": "^4.0.3", diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 194fe43d1d..95db9d3de4 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -37,7 +37,7 @@ import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket- import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app"; import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery"; import { - expectResumeOverflowFallsBackToOneTail, + expectOneResumeCheckWithoutTail, rememberTimelineRequestCounts, } from "../support/helpers/timeline-resume"; import { @@ -466,25 +466,29 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction( } await expect(targetDeckEntry).toHaveCount(0); await subscriptions.waitForSubscribedAgents([ - evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId, + target.agentId, + ...evictionAgents.map((agent) => agent.agentId), ]); gate.setAgentStreamSuppressed(false); await target.client.waitForFinish(target.agentId, 30_000); - const requestsBeforeReturn = rememberTimelineRequestCounts(gate); + const requestsBeforeReturn = rememberTimelineRequestCounts(gate, target.agentId); await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId: target.workspaceId, }); await openAgentRoute(page, target); await expectComposerVisible(page); - await subscriptions.waitForSubscribedAgents([target.agentId]); + await subscriptions.waitForSubscribedAgents([ + target.agentId, + ...evictionAgents.map((agent) => agent.agentId), + ]); const response = page.getByText("(end of synthetic stream)", { exact: true }).last(); await expect(promptRow).toBeVisible(); await expect(response).toBeVisible(); await expectRenderedBefore(promptRow, response); - expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn); + expectOneResumeCheckWithoutTail(gate, requestsBeforeReturn); } finally { gate.setAgentStreamSuppressed(false); gate.restore(); diff --git a/packages/app/e2e/browser/assistant-fork-menu.spec.ts b/packages/app/e2e/browser/assistant-fork-menu.spec.ts index 2819347ab7..b5a0b0c27a 100644 --- a/packages/app/e2e/browser/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/browser/assistant-fork-menu.spec.ts @@ -84,6 +84,7 @@ test.describe("Assistant fork menu", () => { await forkInFlightTurnToNewTab(page); await expectChatHistoryAttachment(page); expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork); + await expect(page.getByRole("button", { name: "Menu backdrop", exact: true })).toHaveCount(0); await page.getByRole("button", { name: sourceAgentTitle }).click(); await expectLiveAssistantText(page, visibleAfterFork); diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index c52bda217a..0d29f0b212 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -355,7 +355,9 @@ test.describe("plugin workspace panels and Command Center", () => { await page.goto(buildAgentRoute(primary.workspaceId, agent.id)); await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 }); - await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0); + // Pressing removed the pill from that page only. The reloaded page evaluates + // the plugin again, and its agents snapshot contributes the pill afresh. + await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible(); await openCompactSidebar(page); // The sidebar's Search row dismisses the compact sidebar on its way to the // command center, so nothing has to close it after the command runs. diff --git a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts index 4a0d2755d6..b941443abe 100644 --- a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts @@ -20,6 +20,7 @@ import { interface ViewedTimelineScenario { client: SeedDaemonClient; workspaceId: string; + repoPath: string; firstAgentId: string; secondAgentId: string; cleanup(): Promise; @@ -45,14 +46,21 @@ async function seedViewedTimelineScenario( return { client: workspace.client, workspaceId: workspace.workspaceId, + repoPath: workspace.repoPath, firstAgentId: firstAgent.id, secondAgentId: secondAgent.id, cleanup: workspace.cleanup, }; } -async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) { - await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId)); +async function openAgent( + page: Page, + scenario: ViewedTimelineScenario, + agentId: string, + options: { recordRenders?: boolean } = {}, +) { + const route = buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId); + await page.goto(options.recordRenders ? `${route}&renderProfile=1` : route); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), ); @@ -63,6 +71,26 @@ async function selectAgent(page: Page, title: string) { await page.getByRole("button", { name: title, exact: true }).click(); } +async function expectForkFailureWithoutOverlappingStatus(page: Page) { + await page.getByRole("button", { name: "Fork chat from here" }).last().click(); + await page.getByRole("menuitem", { name: "Fork in a new tab", exact: true }).click(); + await expect( + page.getByRole("alert").filter({ hasText: "Transport not connected" }), + ).toBeVisible(); + await expectReconnectingToastGone(page, { timeout: 100 }); + await expectReconnectingToastVisible(page); +} + +async function countChatCommits(page: Page, agentId: string) { + return page.evaluate( + (id) => + (globalThis.__PASEO_RENDER_PROFILE__ ?? []).filter( + (sample) => sample.id === `AgentStreamSection:${id}`, + ).length, + agentId, + ); +} + async function enableMoveTabShortcut(page: Page) { await page.addInitScript(() => { Object.defineProperty(navigator, "platform", { get: () => "MacIntel" }); @@ -97,7 +125,59 @@ async function expectAgentConsistentlyIdle(page: Page, title: string): Promise Promise, +) { + const scenario = await seedViewedTimelineScenario(); + try { + await run(scenario); + } finally { + await scenario.cleanup(); + } +} + +async function openSevenChats(page: Page, scenario: ViewedTimelineScenario) { + const subscriptions = observeTimelineSubscriptions(page); + const additional = []; + for (let index = 0; index < 5; index += 1) { + additional.push( + await scenario.client.createAgent({ + provider: "mock", + cwd: scenario.repoPath, + workspaceId: scenario.workspaceId, + title: `Additional chat ${index + 1}`, + modeId: "load-test", + model: "ten-second-stream", + }), + ); + } + await openAgent(page, scenario, scenario.firstAgentId); + await selectAgent(page, "Second viewed chat"); + for (let index = 0; index < additional.length; index += 1) { + await selectAgent(page, `Additional chat ${index + 1}`); + } + await subscriptions.waitForSubscribedAgents([ + scenario.firstAgentId, + scenario.secondAgentId, + ...additional.map((agent) => agent.id), + ]); +} + +async function expectCurrentChatWithoutCatchUp(page: Page, message: string) { + await expect(page.getByText(message, { exact: true })).toBeVisible(); + await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0); +} + test.describe("Viewed agent timelines", () => { + test("open chats stay current after switching beyond five agents", async ({ page }) => { + await withViewedTimelineScenario(async (scenario) => { + await openSevenChats(page, scenario); + await commitMessage(scenario, scenario.firstAgentId, "Current after seven open chats."); + await selectAgent(page, "First viewed chat"); + await expectCurrentChatWithoutCatchUp(page, "Current after seven open chats."); + }); + }); + test("a turn that finishes while hidden reopens with consistently idle chrome", async ({ page, }) => { @@ -119,14 +199,18 @@ test.describe("Viewed agent timelines", () => { } }); - test("a hidden hot chat stays current", async ({ page }) => { + test("a hidden retained chat stays current without rendering", async ({ page }) => { test.setTimeout(60_000); const subscriptions = observeTimelineSubscriptions(page); const scenario = await seedViewedTimelineScenario(); try { - await openAgent(page, scenario, scenario.firstAgentId); + await openAgent(page, scenario, scenario.firstAgentId, { recordRenders: true }); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await composer.fill("Unsent draft survives hidden streaming"); await selectAgent(page, "Second viewed chat"); await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]); + const hiddenCommits = await countChatCommits(page, scenario.firstAgentId); + expect(hiddenCommits).toBeGreaterThan(0); await commitMessage( scenario, scenario.firstAgentId, @@ -135,11 +219,15 @@ test.describe("Viewed agent timelines", () => { await expect( page.getByText("Committed while the first chat is hidden.", { exact: true }), ).toHaveCount(0); + expect(await countChatCommits(page, scenario.firstAgentId)).toBe(hiddenCommits); await selectAgent(page, "First viewed chat"); await expect( page.getByText("Committed while the first chat is hidden.", { exact: true }), ).toBeVisible(); await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); + await expect(composer).toHaveValue("Unsent draft survives hidden streaming"); + await composer.fill("Draft edited after returning"); + await expect(composer).toHaveValue("Draft edited after returning"); } finally { await scenario.cleanup(); } @@ -170,28 +258,46 @@ test.describe("Viewed agent timelines", () => { } }); - test("a visible chat catches up after reconnecting", async ({ page }) => { + test("a visible chat reports updating until reconnect catch-up completes", async ({ + page, + }, testInfo) => { const gate = await installDaemonWebSocketGate(page); const scenario = await seedViewedTimelineScenario(); try { + await commitMessage(scenario, scenario.firstAgentId, "Visible before the connection drops."); await openAgent(page, scenario, scenario.firstAgentId); + const previousMessage = page.getByText("Visible before the connection drops.", { + exact: true, + }); + await expect(previousMessage).toBeVisible(); await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute( "aria-selected", "true", ); await gate.drop(); await gate.waitForBlockedConnection(); + await expectReconnectingToastVisible(page); + await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCSS("opacity", "1"); + await page.screenshot({ path: testInfo.outputPath("reconnecting-chat.png") }); + await expectForkFailureWithoutOverlappingStatus(page); await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects."); await expect( page.getByText("Committed while the chat reconnects.", { exact: true }), ).toHaveCount(0); - // Hold the first authoritative catch-up response so the assertion observes - // the reconnect boundary instead of racing a socket that has not reopened yet. - gate.holdNextServerMessage("fetch_agent_timeline_response"); + // Hold the visible response to observe the connected-but-updating state. + gate.holdTimelineResponses(scenario.firstAgentId); gate.restore(); - await gate.waitForHeldServerMessage("fetch_agent_timeline_response"); - gate.releaseHeldServerMessage("fetch_agent_timeline_response"); + await gate.waitForHeldTimelineResponse(); await expectReconnectingToastGone(page); + await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible(); + await expect( + page.getByTestId("app-toast-message").filter({ hasText: "Updating messages" }), + ).toBeVisible(); + await expect(previousMessage).toBeVisible(); + await expect(page.getByTestId("agent-updating-toast")).toHaveCSS("opacity", "1"); + await page.screenshot({ path: testInfo.outputPath("updating-chat.png") }); + gate.releaseHeldTimelineResponses(); + await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0); const recoveredMessage = page.getByText("Committed while the chat reconnects.", { exact: true, }); diff --git a/packages/app/e2e/support/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts index 00016f0c05..63bc182eaf 100644 --- a/packages/app/e2e/support/helpers/assistant-images.ts +++ b/packages/app/e2e/support/helpers/assistant-images.ts @@ -186,9 +186,15 @@ export async function userPagesUntilAssistantImageRenders( } const previous = await rememberTimelineViewport(page); await userScrollsTimelineToHistoryStart(page); + // Scrolling can bring an already loaded image into the virtualized window + // without increasing content height. Its rendering also satisfies this step. await expect - .poll(async () => (await rememberTimelineViewport(page)).scrollHeight) - .toBeGreaterThan(previous.scrollHeight); + .poll( + async () => + (await rendered.count()) > 0 || + (await rememberTimelineViewport(page)).scrollHeight > previous.scrollHeight, + ) + .toBe(true); } await expectAssistantImageRendered(page, image); } diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 7c9e212c93..bff52fc3f7 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -849,7 +849,12 @@ export async function installDaemonWebSocketGate(page: Page) { getClientRequests(type: string): ReadonlyArray { return [...(clientRequests.get(type) ?? [])]; }, - getTimelineRequestCount(direction: "tail" | "before" | "after"): number { + getTimelineRequestCount(direction: "tail" | "before" | "after", agentId?: string): number { + if (agentId) { + return (clientRequests.get("fetch_agent_timeline_request") ?? []).filter( + (request) => request.agentId === agentId && request.direction === direction, + ).length; + } return timelineRequestCounts.get(direction) ?? 0; }, getAgentStreamItemCount(type: string): number { diff --git a/packages/app/e2e/support/helpers/relay-deployment.ts b/packages/app/e2e/support/helpers/relay-deployment.ts index 3d727e0804..bdbc635a64 100644 --- a/packages/app/e2e/support/helpers/relay-deployment.ts +++ b/packages/app/e2e/support/helpers/relay-deployment.ts @@ -92,7 +92,7 @@ export async function measureRelayRestartDuringStream(input: { agentTitle: string; }): Promise { const { page, relay, agentTitle } = input; - const toast = page.getByTestId("agent-reconnecting-toast"); + const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" }); const beforeOutage = await latestAssistantText(page); await waitForAssistantTextToGrow(page, beforeOutage); diff --git a/packages/app/e2e/support/helpers/timeline-resume.ts b/packages/app/e2e/support/helpers/timeline-resume.ts index 73c921776c..d8926013b2 100644 --- a/packages/app/e2e/support/helpers/timeline-resume.ts +++ b/packages/app/e2e/support/helpers/timeline-resume.ts @@ -6,6 +6,7 @@ import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./w type DaemonWebSocketGate = Awaited>; export interface TimelineRequestCounts { + agentId?: string; after: number; tail: number; } @@ -33,10 +34,14 @@ export async function commitTimelineTurnsWhileDisconnected( }; } -export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): TimelineRequestCounts { +export function rememberTimelineRequestCounts( + gate: DaemonWebSocketGate, + agentId?: string, +): TimelineRequestCounts { return { - after: gate.getTimelineRequestCount("after"), - tail: gate.getTimelineRequestCount("tail"), + agentId, + after: gate.getTimelineRequestCount("after", agentId), + tail: gate.getTimelineRequestCount("tail", agentId), }; } @@ -44,16 +49,16 @@ export function expectOneResumeCheckWithoutTail( gate: DaemonWebSocketGate, before: TimelineRequestCounts, ): void { - expect(gate.getTimelineRequestCount("after") - before.after).toBe(1); - expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0); + expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(1); + expect(gate.getTimelineRequestCount("tail", before.agentId) - before.tail).toBe(0); } export function expectResumeOverflowFallsBackToOneTail( gate: DaemonWebSocketGate, before: TimelineRequestCounts, ): void { - expect(gate.getTimelineRequestCount("after") - before.after).toBe(1); - expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1); + expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(1); + expect(gate.getTimelineRequestCount("tail", before.agentId) - before.tail).toBe(1); } export async function disconnectViewedTimeline( @@ -71,6 +76,8 @@ export async function restoreViewedTimelineWithHeldResponse( gate.holdNextServerMessage("fetch_agent_timeline_response"); gate.restore(); await gate.waitForHeldServerMessage(); - gate.releaseHeldServerMessage(); await expectReconnectingToastGone(page); + await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible(); + gate.releaseHeldServerMessage(); + await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0); } diff --git a/packages/app/e2e/support/helpers/workspace-ui.ts b/packages/app/e2e/support/helpers/workspace-ui.ts index 2daa58bf69..9cedfc5c79 100644 --- a/packages/app/e2e/support/helpers/workspace-ui.ts +++ b/packages/app/e2e/support/helpers/workspace-ui.ts @@ -95,19 +95,18 @@ export async function expectReconnectingToastVisible( page: Page, options?: { timeout?: number }, ): Promise { - const toast = page.getByTestId("agent-reconnecting-toast"); + const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" }); await expect(toast).toBeVisible({ timeout: options?.timeout ?? 30_000, }); - await expect(toast).toHaveText("Reconnecting"); - await expect(toast.getByTestId("agent-reconnecting-status-dot")).toBeVisible(); + await expect(toast).toHaveText("Reconnecting to host"); } export async function expectReconnectingToastGone( page: Page, options?: { timeout?: number }, ): Promise { - await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { + await expect(page.getByRole("alert").filter({ hasText: "Reconnecting to host" })).toHaveCount(0, { timeout: options?.timeout ?? 30_000, }); } diff --git a/packages/app/package.json b/packages/app/package.json index 7e928378c8..eddd82dfde 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -109,6 +109,7 @@ "qrcode": "^1.5.4", "react": "19.1.0", "react-dom": "19.1.0", + "react-freeze": "1.0.4", "react-i18next": "^17.0.8", "react-native": "0.81.5", "react-native-draggable-flatlist": "^4.0.3", diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 4f5bb22de1..18cdb25dea 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -21,6 +21,7 @@ import { type ContextBridge, } from "@/components/ui/isolated-bottom-sheet-modal"; import { SPACING, type Theme } from "@/styles/theme"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { useMenuContext, MenuContextProvider } from "./menu-context"; import { MenuPage } from "./menu-item"; import { currentPageId, isSubPageOpen } from "./menu-navigation"; @@ -125,6 +126,10 @@ export interface MenuSurfaceProps { */ export function MenuSurface(props: MenuSurfaceProps): ReactElement | null { const { presentation } = useMenuContext("MenuSurface"); + const active = useRetainedPanelActive(); + // Portals escape the hidden panel. Remove the surface in its inactive commit, + // before freezing can suspend an async menu action's closing update. + if (!active) return null; if (presentation === "sheet") { return ; } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index e097593db9..ca6138995a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,6 +1,7 @@ import { useRef, ReactNode, useCallback, useEffect } from "react"; import { Buffer } from "buffer"; import { AppState } from "react-native"; +import { observeOpenWorkspaceAgentIds } from "@/stores/workspace-layout-store"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useClientActivity } from "@/hooks/use-client-activity"; @@ -49,7 +50,6 @@ import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; import { useProviderSubagentStore } from "@/subagents/provider-store"; -import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -219,7 +219,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); - const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); const setAgents = useSessionStore((state) => state.setAgents); const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); @@ -256,23 +255,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider viewedTimelineSyncRef.current?.setActive(isAppVisible); }, [isAppVisible]); - const handleAppResumed = useCallback( - (awayMs: number) => { - void revalidateSessionAfterResume({ - awayMs, - serverId, - bumpHistorySyncGeneration, - }); - }, - [bumpHistorySyncGeneration, serverId], - ); - // Client activity tracking (heartbeat, push token registration) useClientActivity({ client, focusedAgentId, focusedTerminalId, - onAppResumed: handleAppResumed, }); useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]); @@ -510,8 +497,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider viewedTimelineSyncRef.current = sync; setViewedTimelineSync(serverId, sync); sync.setActive(getIsAppVisible(appStateRef.current)); + const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) => + sync.replaceOpenAgentIds(agentIds), + ); return () => { + stopObservingOpenChats(); if (viewedTimelineSyncRef.current === sync) { viewedTimelineSyncRef.current = null; } diff --git a/packages/app/src/contexts/session-resume-revalidation.test.ts b/packages/app/src/contexts/session-resume-revalidation.test.ts deleted file mode 100644 index 4c32bcf115..0000000000 --- a/packages/app/src/contexts/session-resume-revalidation.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - revalidateSessionAfterResume, - SESSION_STALE_AFTER_MS, -} from "./session-resume-revalidation"; - -describe("session resume revalidation", () => { - it("refreshes demanded timeline history without manufacturing directory demand", async () => { - const calls: string[] = []; - - const revalidated = await revalidateSessionAfterResume({ - awayMs: SESSION_STALE_AFTER_MS, - serverId: "server", - bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`), - }); - - expect(revalidated).toBe(true); - expect(calls).toEqual(["history:server"]); - }); - - it("does nothing after a brief background interval", async () => { - const calls: string[] = []; - - const revalidated = await revalidateSessionAfterResume({ - awayMs: SESSION_STALE_AFTER_MS - 1, - serverId: "server", - bumpHistorySyncGeneration: () => calls.push("history"), - }); - - expect(revalidated).toBe(false); - expect(calls).toEqual([]); - }); -}); diff --git a/packages/app/src/contexts/session-resume-revalidation.ts b/packages/app/src/contexts/session-resume-revalidation.ts deleted file mode 100644 index 8766ce4cc7..0000000000 --- a/packages/app/src/contexts/session-resume-revalidation.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const SESSION_STALE_AFTER_MS = 60_000; - -export async function revalidateSessionAfterResume(input: { - awayMs: number; - serverId: string; - bumpHistorySyncGeneration: (serverId: string) => void; -}): Promise { - if (input.awayMs < SESSION_STALE_AFTER_MS) { - return false; - } - - input.bumpHistorySyncGeneration(input.serverId); - return true; -} diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index c23512758e..19caef4f8b 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -175,7 +175,7 @@ describe("deriveAgentScreenViewState", () => { expect(sync.ui).toBe("overlay"); }); - it("uses silent catching-up state for already-hydrated agents", () => { + it("shows updating status for already-hydrated agents", () => { const memory = createBaseMemory({ hasRenderedReady: true, lastReadyAgent: createAgent("agent-1"), @@ -190,7 +190,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("silent"); + expect(sync.ui).toBe("status"); }); it("keeps hydrated history visible while reconnect revalidation and visibility catch-up overlap", () => { @@ -209,7 +209,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("silent"); + expect(sync.ui).toBe("status"); }); it("keeps already-hydrated history visible while a newly visible agent catches up", () => { @@ -228,7 +228,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("silent"); + expect(sync.ui).toBe("status"); }); it("keeps hydrated history readable after a visibility catch-up error", () => { @@ -607,7 +607,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("silent"); + expect(sync.ui).toBe("status"); expect(result.memory.hadInitialSyncFailure).toBe(false); }); }); diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index df128e4f2f..591fd1abb1 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -82,7 +82,7 @@ export type AgentScreenReadySyncState = | { status: "reconnecting" } | { status: "catching_up"; - ui: "overlay" | "silent"; + ui: "overlay" | "status" | "silent"; } | { status: "sync_error"; isRetrying: boolean }; @@ -162,9 +162,9 @@ function resolveCatchingUpUi(args: { isVisibilityCatchUpPending: boolean; hasHydratedHistoryBefore: boolean; hadInitialSyncFailure: boolean; -}): "overlay" | "silent" { +}): "overlay" | "status" | "silent" { if (args.hasOptimisticCreateContinuity) return "silent"; - if (args.hasHydratedHistoryBefore) return "silent"; + if (args.hasHydratedHistoryBefore) return "status"; if (args.isVisibilityCatchUpPending) return "overlay"; if (args.hadInitialSyncFailure) return "silent"; return "overlay"; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a969f53cdb..8d45d1decf 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -226,7 +226,8 @@ export const ar: TranslationResources = { states: { notFound: "لم يتم العثور على Agent", failedToLoad: "فشل تحميل الوكيل", - reconnecting: "جارٍ إعادة الاتصال", + reconnecting: "جارٍ إعادة الاتصال بالمضيف", + updating: "جارٍ تحديث الرسائل", timelineSyncFailed: "تعذر تحديث سجل الوكيل.", timelineSyncRetrying: "جارٍ إعادة المحاولة…", archivingTitle: "وكيل الارشيف...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index f95c7e1f0c..272d6d1139 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -223,7 +223,8 @@ export const en = { states: { notFound: "Agent not found", failedToLoad: "Failed to load agent", - reconnecting: "Reconnecting", + reconnecting: "Reconnecting to host", + updating: "Updating messages", timelineSyncFailed: "Couldn't refresh agent history.", timelineSyncRetrying: "Retrying…", archivingTitle: "Archiving agent...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 33f70fa335..99ba8a454f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -227,7 +227,8 @@ export const es: TranslationResources = { states: { notFound: "Agentno encontrado", failedToLoad: "No se pudo cargar el agente", - reconnecting: "Reconectando", + reconnecting: "Reconectando al host", + updating: "Actualizando mensajes", timelineSyncFailed: "No se pudo actualizar el historial del agente.", timelineSyncRetrying: "Reintentando…", archivingTitle: "Agente de archivo...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 22ab73e671..ace3abcdcc 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -229,7 +229,8 @@ export const fr: TranslationResources = { states: { notFound: "Agentintrouvable", failedToLoad: "Échec du chargement de l'agent", - reconnecting: "Reconnexion", + reconnecting: "Reconnexion à l’hôte", + updating: "Mise à jour des messages", timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent.", timelineSyncRetrying: "Nouvelle tentative…", archivingTitle: "Agent d'archivage...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index d482d075b0..2ca151fc54 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -227,7 +227,8 @@ export const ja: TranslationResources = { states: { notFound: "エージェントが見つかりません", failedToLoad: "エージェントの読み込みに失敗しました", - reconnecting: "再接続中", + reconnecting: "ホストに再接続中", + updating: "メッセージを更新中", timelineSyncFailed: "エージェントの履歴を更新できませんでした。", timelineSyncRetrying: "再試行しています…", archivingTitle: "エージェントをアーカイブ中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 080c5f3948..21b74c3a01 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -226,7 +226,8 @@ export const ko: TranslationResources = { states: { notFound: "에이전트를 찾을 수 없습니다", failedToLoad: "에이전트를 불러오지 못했습니다", - reconnecting: "다시 연결하는 중", + reconnecting: "호스트에 다시 연결하는 중", + updating: "메시지 업데이트 중", timelineSyncFailed: "에이전트 기록을 새로고침할 수 없습니다.", timelineSyncRetrying: "재시도 중…", archivingTitle: "에이전트 보관 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8ad31aa91b..6317d54a57 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -226,7 +226,8 @@ export const ptBR: TranslationResources = { states: { notFound: "Agente não encontrado", failedToLoad: "Falha ao carregar agente", - reconnecting: "Reconectando", + reconnecting: "Reconectando ao host", + updating: "Atualizando mensagens", timelineSyncFailed: "Não foi possível atualizar o histórico do agente.", timelineSyncRetrying: "Tentando novamente…", archivingTitle: "Arquivando agente...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index defa3b1241..5f14c32ef7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -227,7 +227,8 @@ export const ru: TranslationResources = { states: { notFound: "Агент не найден", failedToLoad: "Не удалось загрузить агента", - reconnecting: "Повторное подключение", + reconnecting: "Повторное подключение к хосту", + updating: "Обновление сообщений", timelineSyncFailed: "Не удалось обновить историю агента.", timelineSyncRetrying: "Повторная попытка…", archivingTitle: "Агент перемещается в архив...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 9475947913..a2e64d3da6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -226,7 +226,8 @@ export const zhCN: TranslationResources = { states: { notFound: "未找到 Agent", failedToLoad: "加载 Agent 失败", - reconnecting: "正在重连", + reconnecting: "正在重新连接主机", + updating: "正在更新消息", timelineSyncFailed: "无法刷新代理历史记录。", timelineSyncRetrying: "正在重试…", archivingTitle: "正在归档 Agent...", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 5f26342542..7a695ef058 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -27,6 +27,7 @@ import { KeyboardDock } from "@/components/keyboard-dock"; import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; +import { RetainedChatContent } from "./retained-chat-content"; import { Composer } from "@/composer"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { @@ -36,12 +37,7 @@ import { import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { getProviderIcon } from "@/components/provider-icons"; -import { - ToastViewport, - useToastHost, - type ToastApi, - type ToastState, -} from "@/components/toast-host"; +import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { @@ -58,14 +54,12 @@ import { type AgentScreenMissingState, type AgentScreenViewState, useAgentScreenStateMachine, + type AgentScreenReadySyncState, } from "@/hooks/use-agent-screen-state-machine"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; -import { - reconcileReconnectToastState, - type ReconnectToastState, -} from "@/panels/reconnect-toast-state"; +import { TimelineSyncStatus } from "@/timeline/sync-status"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; @@ -73,7 +67,6 @@ import { useHasPluginComposerPills } from "@/plugins"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; import { type HostRuntimeConnectionStatus, - getHostRuntimeConnectionStatusSince, useHostRuntimeClient, useHostRuntimeConnectionStatus, useHostRuntimeIsConnected, @@ -126,10 +119,6 @@ interface ChatAgentStateShape { lastError?: Agent["lastError"] | null; } -const RECONNECT_TOAST_DELAY_MS = 1_000; - -const reconnectToastStateByServerId = new Map(); - interface ChatAgentSelectedState extends ChatAgentStateShape { archivedAt: Date | null; requiresAttention: boolean; @@ -385,13 +374,15 @@ function AgentPanel() { invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( - + + + ); } @@ -578,7 +569,6 @@ function AgentPanelContent({ isPaneFocused={isPaneFocused} client={runtimeClient} isConnected={runtimeIsConnected} - connectionStatus={connectionStatus} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); @@ -591,7 +581,6 @@ function AgentPanelBody({ isPaneFocused, client, isConnected, - connectionStatus, onOpenWorkspaceFile, }: { serverId: string; @@ -600,7 +589,6 @@ function AgentPanelBody({ isPaneFocused: boolean; client: ReturnType; isConnected: boolean; - connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); @@ -736,7 +724,6 @@ function AgentPanelBody({ isPaneFocused={isPaneFocused} client={client} isConnected={isConnected} - connectionStatus={connectionStatus} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); @@ -749,7 +736,6 @@ function ChatAgentContent({ isPaneFocused, client, isConnected, - connectionStatus, onOpenWorkspaceFile, }: { serverId: string; @@ -758,7 +744,6 @@ function ChatAgentContent({ isPaneFocused: boolean; client: ReturnType; isConnected: boolean; - connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); @@ -768,7 +753,6 @@ function ChatAgentContent({ const streamViewRef = useRef(null); const clearOnAgentBlurRef = useRef<() => void>(() => {}); const wasPaneFocusedRef = useRef(isPaneFocused); - const reconnectToastPresentedRef = useRef(false); const initAttemptTokenRef = useRef(0); const routeBottomAnchorRequestRef = useRef<{ routeKey: string; @@ -855,73 +839,6 @@ function ChatAgentContent({ clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; }, [attentionController.clearOnAgentBlur]); - const shouldPresentReconnectToast = - isPaneVisible && connectionStatus !== "online" && connectionStatus !== "idle"; - - useEffect(() => { - if (connectionStatus === "online" || connectionStatus === "idle") { - reconnectToastStateByServerId.delete(serverId); - } - - if (!shouldPresentReconnectToast) { - if (reconnectToastPresentedRef.current) { - reconnectToastPresentedRef.current = false; - dismissToast(); - } - return; - } - - const startedAt = getHostRuntimeConnectionStatusSince(serverId) ?? Date.now(); - const previousReconnectToastState = reconnectToastStateByServerId.get(serverId); - const reconnectToastState = reconcileReconnectToastState( - previousReconnectToastState, - startedAt, - ); - if (reconnectToastState !== previousReconnectToastState) { - reconnectToastStateByServerId.set(serverId, reconnectToastState); - } - - if (reconnectToastState.presented) { - if (!reconnectToastPresentedRef.current) { - reconnectToastPresentedRef.current = true; - toastApi.show(t("agentPanel.states.reconnecting"), { - durationMs: null, - icon: ( - - ), - testID: "agent-reconnecting-toast", - }); - } - return; - } - - const delayMs = Math.max(0, startedAt + RECONNECT_TOAST_DELAY_MS - Date.now()); - const timer = setTimeout(() => { - if (reconnectToastStateByServerId.get(serverId) !== reconnectToastState) { - return; - } - reconnectToastState.presented = true; - reconnectToastPresentedRef.current = true; - toastApi.show(t("agentPanel.states.reconnecting"), { - durationMs: null, - icon: ( - - ), - testID: "agent-reconnecting-toast", - }); - }, delayMs); - - return () => clearTimeout(timer); - }, [connectionStatus, dismissToast, serverId, shouldPresentReconnectToast, toastApi, t]); - const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); useEffect(() => { @@ -1175,6 +1092,7 @@ function ChatAgentContent({ handleComposerHeightChange={handleComposerHeightChange} handleMessageSent={handleMessageSent} handleRewindComplete={handleRewindComplete} + timelineSync={isPaneVisible && viewState.tag === "ready" ? viewState.sync : null} showHistorySyncOverlay={showHistorySyncOverlay} showHistorySyncError={showHistorySyncError} isRetryingHistorySync={isRetryingHistorySync} @@ -1204,6 +1122,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ handleComposerHeightChange, handleMessageSent, handleRewindComplete, + timelineSync, showHistorySyncOverlay, showHistorySyncError, isRetryingHistorySync, @@ -1229,6 +1148,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ handleComposerHeightChange: (height: number) => void; handleMessageSent: () => void; handleRewindComplete: () => void; + timelineSync: AgentScreenReadySyncState | null; showHistorySyncOverlay: boolean; showHistorySyncError: boolean; isRetryingHistorySync: boolean; @@ -1397,7 +1317,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null} - + {isArchivingCurrentAgent ? ( @@ -1872,12 +1792,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, color: theme.colors.foregroundMuted, }, - reconnectingStatusDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: theme.colors.palette.amber[500], - }, centerState: { flex: 1, alignItems: "center", diff --git a/packages/app/src/panels/reconnect-toast-state.test.ts b/packages/app/src/panels/reconnect-toast-state.test.ts deleted file mode 100644 index 2813ca485b..0000000000 --- a/packages/app/src/panels/reconnect-toast-state.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { reconcileReconnectToastState, type ReconnectToastState } from "./reconnect-toast-state"; - -describe("reconnect toast presentation state", () => { - it("resets after the host recovers without a mounted agent panel", () => { - const presented: ReconnectToastState = { - connectionStatusStartedAt: 100, - presented: true, - }; - - expect(reconcileReconnectToastState(presented, 100)).toBe(presented); - expect(reconcileReconnectToastState(presented, 200)).toEqual({ - connectionStatusStartedAt: 200, - presented: false, - }); - }); -}); diff --git a/packages/app/src/panels/reconnect-toast-state.ts b/packages/app/src/panels/reconnect-toast-state.ts deleted file mode 100644 index ff690614fc..0000000000 --- a/packages/app/src/panels/reconnect-toast-state.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface ReconnectToastState { - connectionStatusStartedAt: number; - presented: boolean; -} - -export function reconcileReconnectToastState( - state: ReconnectToastState | undefined, - connectionStatusStartedAt: number, -): ReconnectToastState { - if (state?.connectionStatusStartedAt === connectionStatusStartedAt) { - return state; - } - - return { - connectionStatusStartedAt, - presented: false, - }; -} diff --git a/packages/app/src/panels/retained-chat-content.tsx b/packages/app/src/panels/retained-chat-content.tsx new file mode 100644 index 0000000000..0ed1d2dfc8 --- /dev/null +++ b/packages/app/src/panels/retained-chat-content.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +/** + * Native: a hidden chat stays mounted and keeps rendering. Suspending a retained + * native subtree hides its host views, tears down layout effects, and detaches + * refs, which changes Fabric ownership (docs/mobile-panels.md). Activity-gated + * work already stops through the retained-panel signal. + */ +export function RetainedChatContent({ children }: { children: ReactNode }) { + return children; +} diff --git a/packages/app/src/panels/retained-chat-content.web.tsx b/packages/app/src/panels/retained-chat-content.web.tsx new file mode 100644 index 0000000000..938a0e48ca --- /dev/null +++ b/packages/app/src/panels/retained-chat-content.web.tsx @@ -0,0 +1,17 @@ +import React, { useEffect, useState, type ReactNode } from "react"; +import { Freeze } from "react-freeze"; +import { useRetainedPanelActive } from "@/components/retained-panel"; + +/** + * Web only: suspends a hidden chat so its subscribers stop rendering while the + * model keeps streaming. DOM nodes have no Fabric ownership to lose, so + * suspension is safe here; native keeps the subtree live (see the base file). + */ +export function RetainedChatContent({ children }: { children: ReactNode }) { + const active = useRetainedPanelActive(); + const [frozen, setFrozen] = useState(!active); + // First let visibility-gated queries and animations receive active=false. + // The following commit freezes every chat subscriber while the model keeps streaming. + useEffect(() => setFrozen(!active), [active]); + return {children}; +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index fbaac8b7df..7e9f619fc6 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -36,6 +36,7 @@ class FakeDaemonClient { private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; public connectCalls = 0; public ensureConnectedCalls = 0; + public connectionVerifications = 0; public reconnectEnabledChanges: boolean[] = []; public fetchAgentsCalls: FetchAgentsOptions[] = []; public fetchAgentsResponses: Array< @@ -120,8 +121,9 @@ class FakeDaemonClient { }); } - ensureConnected(): void { + ensureConnected(options?: { verify?: boolean }): void { this.ensureConnectedCalls += 1; + if (options?.verify) this.connectionVerifications += 1; if (this.state.status !== "connected") { this.setConnectionState({ status: "connected" }); } @@ -1631,6 +1633,8 @@ describe("HostRuntimeStore", () => { expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); expect(clientA.ensureConnectedCalls).toBe(1); + expect(clientA.connectionVerifications).toBe(1); + expect(clientB.connectionVerifications).toBe(1); expect(clientB.ensureConnectedCalls).toBe(1); expect(store.getSnapshot(hostA.serverId)?.connectionStatus).toBe("online"); expect(store.getSnapshot(hostB.serverId)?.connectionStatus).toBe("online"); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 416cff911e..37154eab3e 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -722,8 +722,8 @@ export class HostRuntimeController { await this.runProbeCycleNow(); } - ensureConnected(): void { - this.activeClient?.ensureConnected(); + ensureConnected(options?: { verify?: boolean }): void { + this.activeClient?.ensureConnected(options); } markAgentDirectorySyncLoading(): void { @@ -2320,9 +2320,9 @@ export class HostRuntimeStore { return earliestServerId; } - ensureConnectedAll(): void { + ensureConnectedAll(options?: { verify?: boolean }): void { for (const controller of this.controllers.values()) { - controller.ensureConnected(); + controller.ensureConnected(options); } } @@ -2334,7 +2334,7 @@ export class HostRuntimeStore { return; } - this.ensureConnectedAll(); + this.ensureConnectedAll({ verify: true }); } runProbeCycleNow(serverId?: string): Promise { diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 26e0736945..37076a4a72 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -24,6 +24,7 @@ import { collectAllPanes, collectAllTabs, createWorkspaceLayoutStore, + observeOpenWorkspaceAgentIds, createDefaultLayout, createWorkspaceLayoutWithExplorerSidebar, findPaneById, @@ -73,6 +74,39 @@ function createDeterministicWorkspaceLayoutIds() { const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds(); const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds); +it("observes open chats across unmounted workspaces until their tabs close", () => { + const store = createWorkspaceLayoutStore(workspaceLayoutIds); + store.setState({ layoutByWorkspace: {} }); + const received: string[][] = []; + const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store); + const first = store.getState().openTab({ + workspaceKey: "server-1:workspace-a", + target: { kind: "agent", agentId: "agent-a" }, + intent: "reveal", + }); + store.getState().openTab({ + workspaceKey: "server-1:workspace-b", + target: { kind: "agent", agentId: "agent-b" }, + intent: "background", + }); + store.getState().openTab({ + workspaceKey: "server-2:workspace-a", + target: { kind: "agent", agentId: "other-host-agent" }, + intent: "reveal", + }); + expect(received).toEqual([[], ["agent-a"], ["agent-a", "agent-b"]]); + if (!first) throw new Error("Expected an open agent tab"); + store.getState().closeTab("server-1:workspace-a", first); + expect(received.at(-1)).toEqual(["agent-b"]); + stop(); + store.getState().openTab({ + workspaceKey: "server-1:workspace-a", + target: { kind: "agent", agentId: "after-dispose" }, + intent: "reveal", + }); + expect(received).toEqual([[], ["agent-a"], ["agent-a", "agent-b"], ["agent-b"]]); +}); + function useWorkspaceLayoutIds(...values: string[]) { workspaceLayoutIds.useValues(values); } diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index b7b9d57677..31fa370ca7 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1750,6 +1750,34 @@ export function createWorkspaceLayoutStore( export const useWorkspaceLayoutStore = createWorkspaceLayoutStore(); +/** Observe open chats independently of which workspace views are mounted. */ +export function observeOpenWorkspaceAgentIds( + serverId: string, + listener: (agentIds: string[]) => void, + store = useWorkspaceLayoutStore, +): () => void { + let previous: string[] | undefined; + const publish = (state: Pick) => { + const ids = new Set(); + for (const [workspaceKey, layout] of Object.entries(state.layoutByWorkspace)) { + if (!workspaceKey.startsWith(`${serverId}:`)) continue; + for (const tab of collectAllTabs(layout.root)) { + if (tab.target.kind === "agent") ids.add(tab.target.agentId); + } + } + const next = [...ids].sort(); + if (previous?.length === next.length && previous.every((id, index) => id === next[index])) + return; + previous = next; + listener(next); + }; + const unsubscribe = store.subscribe((state, before) => { + if (state.layoutByWorkspace !== before.layoutByWorkspace) publish(state); + }); + publish(store.getState()); + return unsubscribe; +} + export function useWorkspaceLayoutStoreHydrated(): boolean { const [hasHydrated, setHasHydrated] = useState(() => useWorkspaceLayoutStore.persist.hasHydrated(), diff --git a/packages/app/src/timeline/sync-status.tsx b/packages/app/src/timeline/sync-status.tsx new file mode 100644 index 0000000000..c4d74e400b --- /dev/null +++ b/packages/app/src/timeline/sync-status.tsx @@ -0,0 +1,49 @@ +import type { Theme } from "@/styles/theme"; +import { useMemo } from "react"; +import type { AgentScreenReadySyncState } from "@/hooks/use-agent-screen-state-machine"; +import { useTranslation } from "react-i18next"; +import { withUnistyles } from "react-native-unistyles"; +import { ToastViewport, type ToastState } from "@/components/toast-host"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; + +const spinnerColor = (theme: Theme) => ({ color: theme.colors.foreground }); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const keepUntilSynchronized = () => {}; + +export function TimelineSyncStatus({ + sync, + toast, + onDismiss, +}: { + sync: AgentScreenReadySyncState | null; + toast: ToastState | null; + onDismiss: () => void; +}) { + const { t } = useTranslation(); + let state: "reconnecting" | "updating" | null = null; + if (sync?.status === "reconnecting") state = "reconnecting"; + else if (sync?.status === "catching_up" && sync.ui === "status") state = "updating"; + const label = state ? t(`agentPanel.states.${state}`) : null; + const syncToast = useMemo( + () => + state && label + ? { + id: state === "reconnecting" ? 1 : 2, + content: label, + nativeMessage: label, + icon: , + variant: "default", + durationMs: null, + testID: `agent-${state}-toast`, + } + : null, + [state, label], + ); + return ( + + ); +} diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 9a77ab8a6a..ed1c9e71c7 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -38,6 +38,87 @@ interface TimelineFetch { fail(message: string): void; } +test("split panes catch up together before hidden open chats start fetching", async () => { + const world = new TimelineWorld(); + world.sync.replaceOpenAgentIds(["a-hidden", "y-visible", "z-visible"]); + world.sync.replaceVisibleAgentIds("panes", ["y-visible", "z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const left = await world.nextFetch("y-visible"); + const right = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + left.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("y-visible")).toBe("ready")); + world.expectNoPendingFetch(); + right.respond({ hasNewer: false }); + (await world.nextFetch("a-hidden")).respond({ hasNewer: false }); + world.sync.dispose(); +}); + +test("backgrounding releases hidden catch-ups waiting on a visible chat", async () => { + const world = new TimelineWorld(); + world.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]); + world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const visible = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + world.sync.setActive(false); + (await world.nextFetch("a-hidden")).respond({ hasNewer: false }); + visible.respond({ hasNewer: false }); + world.sync.dispose(); +}); + +test("visible catch-up settles before hidden open chats start fetching", async () => { + const world = new TimelineWorld(); + world.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]); + world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const visible = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + visible.respond({ hasNewer: false }); + const hidden = await world.nextFetch("a-hidden"); + expect(world.sync.getAgentTimelineStatus("z-visible")).toBe("ready"); + hidden.respond({ hasNewer: false }); + world.sync.dispose(); +}); + +test("a failed visible catch-up releases hidden chats without bypassing its retry delay", async () => { + const world = new TimelineWorld(); + world.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]); + world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const visible = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + visible.fail("temporarily unavailable"); + const hidden = await world.nextFetch("a-hidden"); + hidden.respond({ hasNewer: false }); + await world.nextError(); + world.expectNoPendingFetch(); + (await world.nextRetry())(); + (await world.nextFetch("z-visible")).respond({ hasNewer: false }); + world.sync.dispose(); +}); + +test("switching chats promotes the newly visible catch-up while another visible request is pending", async () => { + const world = new TimelineWorld(); + world.sync.replaceOpenAgentIds(["a-hidden", "b-hidden", "z-visible"]); + world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const previous = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + world.sync.replaceVisibleAgentIds("pane", ["b-hidden"]); + const current = await world.nextFetch("b-hidden"); + world.expectNoPendingFetch(); + current.respond({ hasNewer: false }); + (await world.nextFetch("a-hidden")).respond({ hasNewer: false }); + previous.respond({ hasNewer: false }); + world.sync.dispose(); +}); + class TimelineWorld { constructor(private readonly observeMembership?: ViewedTimelineSyncPorts["observe"]) {} readonly errors: string[] = []; @@ -121,6 +202,14 @@ class TimelineWorld { }, }); + private openAgentIds = new Set(); + + show(sourceId: string, agentIds: string[]): void { + for (const id of agentIds) this.openAgentIds.add(id); + this.sync.replaceOpenAgentIds([...this.openAgentIds]); + this.sync.replaceVisibleAgentIds(sourceId, agentIds); + } + private readonly memberships: MembershipRequest[] = []; private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = []; private readonly fetches: TimelineFetch[] = []; @@ -380,12 +469,10 @@ test("all acknowledged agents begin catch-up independently", async () => { const membership = await world.nextMembership(); membership.succeed(); - const [agentA, agentB] = await Promise.all([ - world.nextFetch("agent-a"), - world.nextFetch("agent-b"), - ]); - agentA.respond({ hasNewer: false }); + const agentB = await world.nextFetch("agent-b"); agentB.respond({ hasNewer: false }); + const agentA = await world.nextFetch("agent-a"); + agentA.respond({ hasNewer: false }); expect(membership.agentIds).toEqual(["agent-a", "agent-b"]); }); @@ -481,7 +568,7 @@ test("an eviction starts and acknowledges B before A returns its late subscripti ), ); expect(a.agentIds).toEqual(["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]); - expect(b.agentIds).toEqual(["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"]); + expect(b.agentIds).toEqual(["agent-f"]); world.expectNoPendingFetch(); expect(world.errors).toEqual([]); world.sync.dispose(); @@ -498,6 +585,33 @@ test("an eviction starts and acknowledges B before A returns its late subscripti } }); +test("open chats remain subscribed beyond five views and without mounted workspace panes", async () => { + const world = new TimelineWorld(); + const agents = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]; + world.sync.replaceOpenAgentIds(agents); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.setConnected(true); + const membership = await world.nextMembership(); + expect(membership.agentIds).toEqual(agents); + membership.succeed(); + for (const agentId of agents) { + (await world.nextFetch(agentId)).respond({ hasNewer: false }); + } + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + world.sync.replaceVisibleAgentIds("workspace", []); + world.expectNoPendingMembership(); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.expectNoPendingFetch(); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"); + + world.sync.replaceOpenAgentIds(agents.slice(0, 5)); + const closed = await world.nextMembership(); + expect(closed.agentIds).toEqual(agents.slice(0, 5)); + closed.succeed(); + world.sync.dispose(); +}); + test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -536,10 +650,10 @@ test("navigation while disconnected reconnects only the currently visible agent" world.expectNoPendingFetch(); }); -test("overlapping sources deduplicate membership and retain hidden hot agents", async () => { +test("overlapping sources deduplicate membership and retain open hidden chats", async () => { const world = new TimelineWorld(); - world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]); - world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]); + world.show("left-route", ["agent-a"]); + world.show("right-route", ["agent-a", "agent-b"]); world.sync.setConnected(true); const combined = await world.nextMembership(); combined.succeed(); @@ -550,9 +664,9 @@ test("overlapping sources deduplicate membership and retain hidden hot agents", agentA.respond({ hasNewer: false }); agentB.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("left-route", []); + world.show("left-route", []); world.expectNoPendingMembership(); - world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]); + world.show("right-route", ["agent-b"]); expect(combined.agentIds).toEqual(["agent-a", "agent-b"]); world.expectNoPendingMembership(); @@ -788,16 +902,37 @@ test("redeclaring unchanged visibility does not bypass membership backoff", asyn expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); }); -test("backgrounding preserves the hot membership without catch-up on return", async () => { +test("foreground recovery skips to the latest tail after one missed page", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); + + world.sync.setActive(false); + world.sync.setActive(true); + const probe = await world.nextFetch("agent-a"); + expect(probe.request.direction).toBe("after"); + probe.respond({ hasNewer: true, seq: 82 }); + const latest = await world.nextFetch("agent-a"); + expect(latest.request.direction).toBe("tail"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); + latest.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); +}); + +test("backgrounding preserves subscriptions and returning verifies the visible chat", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.show("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -810,27 +945,29 @@ test("backgrounding preserves the hot membership without catch-up on return", as world.sync.setActive(true); world.expectNoPendingMembership(); + expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("pending"); + const resume = await world.nextFetch("agent-b"); + resume.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); world.expectNoPendingFetch(); }); test("stale membership retry cannot overwrite a newer effective set", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.show("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const staleRetry = await world.nextRetry(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.show("workspace", ["agent-b"]); const current = await world.nextMembership(); staleRetry(); current.succeed(); - const [agentA, agentB] = await Promise.all([ - world.nextFetch("agent-a"), - world.nextFetch("agent-b"), - ]); - agentA.respond({ hasNewer: false }); + const agentB = await world.nextFetch("agent-b"); agentB.respond({ hasNewer: false }); + const agentA = await world.nextFetch("agent-a"); + agentA.respond({ hasNewer: false }); expect(current.agentIds).toEqual(["agent-a", "agent-b"]); world.expectNoPendingMembership(); @@ -856,16 +993,16 @@ test("membership retry cannot run while disconnected", async () => { expect(restored.agentIds).toEqual(["agent-a"]); }); -test("returning to a hot hidden agent stays live after inactivity", async () => { +test("returning to an open hidden agent stays live after inactivity", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.show("workspace", ["agent-a"]); const initialMembership = await world.nextMembership(); initialMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.show("workspace", ["agent-b"]); const expandedMembership = await world.nextMembership(); expandedMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -873,48 +1010,13 @@ test("returning to a hot hidden agent stays live after inactivity", async () => await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); world.elapse(60_000); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.show("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); }); -test("the hot set evicts the least-recent hidden agent and promotes revisited agents", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - for (const agentId of ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]) { - world.sync.replaceVisibleAgentIds("workspace", [agentId]); - const membership = await world.nextMembership(); - membership.succeed(); - const catchUp = await world.nextFetch(agentId); - catchUp.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus(agentId)).toBe("ready")); - } - - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); - world.expectNoPendingMembership(); - world.expectNoPendingFetch(); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); - const eviction = await world.nextMembership(); - eviction.succeed(); - const agentFCatchUp = await world.nextFetch("agent-f"); - agentFCatchUp.respond({ hasNewer: false }); - expect(eviction.agentIds).toEqual(["agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); - world.expectNoPendingMembership(); - world.expectNoPendingFetch(); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const restoreEvicted = await world.nextMembership(); - restoreEvicted.succeed(); - const agentACatchUp = await world.nextFetch("agent-a"); - agentACatchUp.respond({ hasNewer: false }); - expect(restoreEvicted.agentIds).toEqual(["agent-a", "agent-b", "agent-d", "agent-e", "agent-f"]); -}); - -test("visible agents are never evicted when they exceed the hot-set limit", async () => { +test("visible agents stay subscribed even before open-tab membership arrives", async () => { const world = new TimelineWorld(); const visible = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]; world.sync.setConnected(true); @@ -932,7 +1034,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5)); }); -test("disconnect clears hidden hot agents before reconnecting the visible set", async () => { +test("disconnect restores visible demand and revisiting a closed chat fetches again", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); @@ -963,7 +1065,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", reopened.succeed(); const reopenedCatchUp = await world.nextFetch("agent-a"); reopenedCatchUp.respond({ hasNewer: false }); - expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]); + expect(reopened.agentIds).toEqual(["agent-a"]); }); test("disposing a view releases its pending observation before bootstrap completes", async () => { diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index ea73f9e8e2..8bc2ca83f7 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -332,6 +332,7 @@ export interface ViewedTimelineUiBridge { } export interface ViewedTimelineSync extends ViewedTimelineUiBridge { + replaceOpenAgentIds(agentIds: string[]): void; setActive(active: boolean): void; setConnected(connected: boolean): void; recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; @@ -406,7 +407,6 @@ export function createViewedTimelineOwner(input: { const RETRY_DELAY_MS = 1_000; const MAX_RETRY_DELAY_MS = 30_000; -const VIEWED_TIMELINE_HOT_AGENT_LIMIT = 5; type CatchUpStatus = "running" | "complete" | "error"; @@ -472,7 +472,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const catchUps = new Map(); const catchUpGenerations = new Map(); // Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked. - // Acknowledgement and tail completion are the only drain points. + // Membership acknowledgement and catch-up completion drain parked requests. const pendingCatchUps = new Map(); const visibilityCatchUpPending = new Set(); const visibilityCatchUpErrors = new Map(); @@ -495,36 +495,16 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed let membershipNeedsRetry = false; let membershipRetryDelayMs: number | undefined; let cancelMembershipRetry: (() => void) | null = null; - let recentlyViewedAgentIds: string[] = []; + let openAgentIds: string[] = []; const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat()); - const selectHotAgentIds = (visible: string[]) => { - const visibleSet = new Set(visible); - recentlyViewedAgentIds = [ - ...visible, - ...recentlyViewedAgentIds.filter((agentId) => !visibleSet.has(agentId)), - ]; - const hiddenBudget = Math.max(0, VIEWED_TIMELINE_HOT_AGENT_LIMIT - visible.length); - const desiredAgentIds = normalizeAgentIds([ - ...visible, - ...recentlyViewedAgentIds - .filter((agentId) => !visibleSet.has(agentId)) - .slice(0, hiddenBudget), - ]); - const desiredSet = new Set(desiredAgentIds); - recentlyViewedAgentIds = recentlyViewedAgentIds.filter((agentId) => desiredSet.has(agentId)); - return desiredAgentIds; - }; - const isAcknowledged = (agentId: string) => acknowledged.includes(agentId); const isDesired = (agentId: string) => desired.includes(agentId); + const canCatchUp = (agentId: string) => + !disposed && connected && isDesired(agentId) && isAcknowledged(agentId); const ownsCatchUp = (agentId: string, generation: number) => - !disposed && - connected && - isDesired(agentId) && - isAcknowledged(agentId) && - catchUps.get(agentId)?.generation === generation; + canCatchUp(agentId) && catchUps.get(agentId)?.generation === generation; const notifyListeners = () => { for (const listener of listeners) listener(); @@ -572,6 +552,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (page.hasNewer && page.endCursor) { if (fallbackToLatestTailOnOverflow) { await ports.fetchLatestTail(agentId); + if (!ownsCatchUp(agentId, generation)) return; catchUps.set(agentId, { generation, status: "complete" }); setVisibilityCatchUpReady(agentId); return; @@ -612,6 +593,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed setVisibilityCatchUpError([agentId], error); ports.reportError(error); } + } finally { + startAcknowledgedCatchUps(); } }; @@ -629,6 +612,19 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed cacheLoads.set(agentId, load); }; + // Existing streams and retries continue; only initial hidden catch-ups wait. + const shouldWaitForVisibleCatchUp = (agentId: string) => { + if (!active || catchUps.has(agentId)) return false; + const visible = visibleAgentIds(); + return ( + !visible.includes(agentId) && + visible.some((id) => { + const status = catchUps.get(id)?.status; + return isDesired(id) && (status === undefined || status === "running"); + }) + ); + }; + const startCatchUp = ( agentId: string, options: { @@ -637,7 +633,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } = {}, ) => { const { request, supersede = false } = options; - if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) { + if (!canCatchUp(agentId)) { + if (request) pendingCatchUps.set(agentId, request); + return; + } + if (shouldWaitForVisibleCatchUp(agentId)) { if (request) pendingCatchUps.set(agentId, request); return; } @@ -677,8 +677,17 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed }; const startAcknowledgedCatchUps = () => { - for (const agentId of acknowledged) { + const visible = visibleAgentIds(); + const ordered = [ + ...visible.filter(isAcknowledged), + ...acknowledged.filter((id) => !visible.includes(id)), + ]; + for (const agentId of ordered) { const pendingCatchUp = pendingCatchUps.get(agentId); + const current = catchUps.get(agentId); + // Completion of a different chat must not bypass a failed chat's backoff or + // supersede an in-flight tail that owns its parked gap recovery. + if (current && !(current.status === "complete" && pendingCatchUp)) continue; startCatchUp(agentId, { request: pendingCatchUp, supersede: Boolean(pendingCatchUp), @@ -786,15 +795,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed }; const publishVisibleMembership = () => { - const visible = visibleAgentIds(); - if (!connected) { - const activeVisible = active ? visible : []; - recentlyViewedAgentIds = activeVisible; - commitDesiredMembership(activeVisible); - return; - } - if (!active) return; - commitDesiredMembership(selectHotAgentIds(visible)); + commitDesiredMembership(normalizeAgentIds([...openAgentIds, ...visibleAgentIds()])); }; return { @@ -811,24 +812,39 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed getAgentTimelineError(agentId) { return visibilityCatchUpErrors.get(agentId) ?? null; }, + replaceOpenAgentIds(agentIds) { + const next = normalizeAgentIds(agentIds); + if (sameAgentIds(next, openAgentIds)) return; + openAgentIds = next; + publishVisibleMembership(); + }, replaceVisibleAgentIds(sourceId, agentIds) { const normalized = normalizeAgentIds(agentIds); if (normalized.length === 0) sources.delete(sourceId); else sources.set(sourceId, normalized); publishVisibleMembership(); + startAcknowledgedCatchUps(); }, setActive(nextActive) { if (active === nextActive) return; active = nextActive; publishVisibleMembership(); + if (!active) { + startAcknowledgedCatchUps(); + return; + } + for (const agentId of visibleAgentIds()) { + visibilityCatchUpPending.add(agentId); + visibilityCatchUpErrors.delete(agentId); + startCatchUp(agentId, { supersede: true }); + } + notifyListeners(); }, setConnected(nextConnected) { if (connected === nextConnected) return; connected = nextConnected; if (!connected) { - const visible = active ? visibleAgentIds() : []; - recentlyViewedAgentIds = visible; - commitDesiredMembership(visible, { resetCatchUpStatus: true }); + commitDesiredMembership(desired, { resetCatchUpStatus: true }); cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; @@ -863,7 +879,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed desired = []; ports.replaceDemandedAgentIds([]); acknowledged = []; - recentlyViewedAgentIds = []; + openAgentIds = []; loadedCache.clear(); cacheLoads.clear(); visibilityCatchUpPending.clear(); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 4d36bb686d..c2860c9b4d 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -419,8 +419,13 @@ class FakeDaemon { if (typeof data !== "string") { return; } - const frame = JSON.parse(data) as { type?: string }; - if (frame.type !== "ping") { + const frame = JSON.parse(data) as { + type?: string; + message?: { type?: string; requestId: string; clientSentAt: number }; + }; + const sessionPing = + frame.type === "session" && frame.message?.type === "ping" ? frame.message : null; + if (frame.type !== "ping" && !sessionPing) { return; } this.pingsSentAt.push(performance.now()); @@ -431,12 +436,18 @@ class FakeDaemon { if (this.pongMode.kind === "silent") { return; } + const pong = sessionPing + ? wrapSessionMessage({ + type: "pong", + payload: { ...sessionPing, serverReceivedAt: Date.now(), serverSentAt: Date.now() }, + }) + : JSON.stringify({ type: "pong" }); if (this.pongMode.delayMs === 0) { - this.onMessage(JSON.stringify({ type: "pong" })); + this.onMessage(pong); return; } setTimeout(() => { - this.onMessage(JSON.stringify({ type: "pong" })); + this.onMessage(pong); }, this.pongMode.delayMs); }, close: (code?: number, reason?: string) => { @@ -1717,6 +1728,78 @@ test("keeps default connect timeout shorter than session RPC waiters", async () } }); +test("foreground verification replaces a silently broken socket without waiting for heartbeats", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + let attempts = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "foreground-recovery", + logger: noopLogger, + transportFactory: () => (++attempts === 1 ? first : second).transport, + }); + clients.push(client); + const connection = client.connect(); + first.openConnection(); + await connection; + + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(3_000); + expect(attempts).toBe(2); + second.openConnection(); + expect(client.getConnectionState()).toEqual({ status: "connected" }); +}); + +test("foreground verification preserves a healthy socket and deduplicates simultaneous checks", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonAnswersPingsAfter("0.1s"); + const client = new DaemonClient({ + url: "ws://test", + clientId: "healthy-resume", + logger: noopLogger, + transportFactory: () => daemon.transport, + }); + clients.push(client); + const connection = client.connect(); + daemon.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(3_000); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(daemon.pingTimestamps()).toEqual(["0s"]); + expect(daemon.closesFromClient()).toEqual([]); +}); + +test("an obsolete foreground probe cannot close a replacement connection", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + let attempts = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "resume-race", + logger: noopLogger, + transportFactory: () => (++attempts === 1 ? first : second).transport, + }); + clients.push(client); + const connection = client.connect(); + first.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + first.daemonClosesWith("network changed"); + client.ensureConnected(); + second.openConnection(); + await vi.advanceTimersByTimeAsync(3_000); + expect(attempts).toBe(2); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(second.closesFromClient()).toEqual([]); +}); + test("stays online through ten minutes of pongs that arrive five seconds late", async () => { useHeartbeatClock(); const session = new DaemonClientSession(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 458ccf5c92..d72b9ab504 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1167,6 +1167,7 @@ export class DaemonClient { private runtimeMetricsInterval: ReturnType | null = null; private runtimeMetrics: DaemonClientRuntimeMetrics | null = null; private pingProbe: PingProbe | null = null; + private connectionVerification: DaemonTransport | null = null; private livenessHeartbeatTimer: ReturnType | null = null; private lastLivenessRttMs: number | null = null; private consecutiveLivenessFailures = 0; @@ -1451,19 +1452,18 @@ export class DaemonClient { ); } - ensureConnected(): void { + ensureConnected(options?: { verify?: boolean }): void { if (this.connectionState.status === "disposed") { return; } if (!this.shouldReconnect) { this.shouldReconnect = true; } - if ( - this.connectionState.status === "connected" || - this.connectionState.status === "connecting" - ) { + if (this.connectionState.status === "connected") { + if (options?.verify) this.verifyConnection(); return; } + if (this.connectionState.status === "connecting") return; if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; @@ -1475,6 +1475,28 @@ export class DaemonClient { void this.connect(); } + private verifyConnection(): void { + const transport = this.transport; + if (!transport || this.connectionVerification === transport) return; + this.connectionVerification = transport; + // A session probe has its own deadline, independent of a heartbeat that the OS + // may have suspended. A successful response also proves the session can serve RPCs. + void this.ping({ timeoutMs: 3_000 }) + .catch((error: unknown) => { + if (this.transport !== transport || this.connectionState.status !== "connected") return; + this.disposeTransport(1001, "Connection verification failed"); + this.scheduleReconnect({ + reason: error instanceof Error ? error.message : String(error), + event: "CONNECTION_VERIFICATION_FAILED", + reasonCode: "liveness_timeout", + }); + this.ensureConnected(); + }) + .finally(() => { + if (this.connectionVerification === transport) this.connectionVerification = null; + }); + } + getConnectionState(): ConnectionState { return this.connectionState; } diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts index cf8b2adc2a..3d7a42efdb 100644 --- a/packages/server/src/server/creation/index.ts +++ b/packages/server/src/server/creation/index.ts @@ -9,7 +9,7 @@ import { type CreationSnapshot, type WorkspaceDescriptorPayload, } from "@getpaseo/protocol/messages"; -import { writeJsonFileAtomic } from "../atomic-file.js"; +import { writeFileAtomic } from "../atomic-file.js"; import { generateWorkspaceId } from "../workspace-registry-model.js"; type Observer = (snapshot: CreationSnapshot) => void; @@ -46,6 +46,7 @@ type Record = z.infer; export class CreationService { private admission: Promise = Promise.resolve(); private readonly active = new Map>(); + private readonly receiptOperations = new Map>(); private readonly observers = new Map>(); constructor( private readonly directory: string, @@ -305,11 +306,32 @@ export class CreationService { this.notify(observer, record.snapshot); } private write(identity: string, record: Record): Promise { - return writeJsonFileAtomic(join(this.directory, `${identity}.json`), record); + // Serialize now: the runner mutates the record after queueing this write. + const contents = JSON.stringify(record, null, 2); + return this.accessReceipt(identity, () => + writeFileAtomic(join(this.directory, `${identity}.json`), contents), + ); } - private async read(identity: string): Promise { - const text = await readOptional(join(this.directory, `${identity}.json`)); - return text === null ? null : RecordSchema.parse(JSON.parse(text)); + private read(identity: string): Promise { + return this.accessReceipt(identity, async () => { + const text = await readOptional(join(this.directory, `${identity}.json`)); + return text === null ? null : RecordSchema.parse(JSON.parse(text)); + }); + } + private accessReceipt(identity: string, access: () => Promise): Promise { + // Admission and the runner progress independently. Keep this owner from + // holding a receipt read open while atomically replacing the same file. + const previous = this.receiptOperations.get(identity) ?? Promise.resolve(); + const result = previous.then(access); + const settled = result.then( + () => undefined, + () => undefined, + ); + this.receiptOperations.set(identity, settled); + void settled.finally(() => { + if (this.receiptOperations.get(identity) === settled) this.receiptOperations.delete(identity); + }); + return result; } } function initialRecord(input: CreationInput, fingerprint: string): Record { diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index 6e9f4036ed..11e973c136 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -66,11 +66,34 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts hub.requestOrdinary({ ...create, requestId: "create-duplicate" }), ]); const first = responses[0]; - expect(first).toMatchObject({ type: "status", payload: { status: "agent_created" } }); + expect(first).toEqual({ + type: "status", + payload: { + status: "agent_created", + requestId: "create-first", + agentId: expect.any(String), + agent: expect.objectContaining({ + id: expect.any(String), + workspaceId: expect.any(String), + provider: "codex", + cwd: hub.repoRoot(), + status: "idle", + }), + }, + }); if (first?.type !== "status" || first.payload.status !== "agent_created") throw new Error("Agent was not created"); const agentId = first.payload.agentId; - expect(responses[1]).toMatchObject({ type: "status", payload: { agentId } }); + expect(first.payload.agent.id).toBe(agentId); + expect(responses[1]).toEqual({ + type: "status", + payload: { + status: "agent_created", + requestId: "create-duplicate", + agentId, + agent: first.payload.agent, + }, + }); expect( await hub.requestOrdinary({ type: "fetch_agents_request", @@ -92,11 +115,13 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts text: "hello", activeTurnBehavior: "steer", }; - expect(await hub.requestOrdinary({ ...message, requestId: "message-first" })).toMatchObject({ - payload: { accepted: true }, + expect(await hub.requestOrdinary({ ...message, requestId: "message-first" })).toEqual({ + type: "send_agent_message_response", + payload: { requestId: "message-first", agentId, accepted: true, error: null }, }); - expect(await hub.requestOrdinary({ ...message, requestId: "message-duplicate" })).toMatchObject({ - payload: { accepted: true }, + expect(await hub.requestOrdinary({ ...message, requestId: "message-duplicate" })).toEqual({ + type: "send_agent_message_response", + payload: { requestId: "message-duplicate", agentId, accepted: true, error: null }, }); expect( hub