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