Keep open chats current and show reconnection progress (#4863)

* fix(app): keep open chats current and show resume progress

Keep open-chat delivery independent of mounted views, freeze hidden chat rendering, and catch up visible chats before hidden ones. Distinguish host reconnection from timeline freshness so cached messages do not silently appear current after resume.

* fix(app): prevent sync status from overlapping action errors

Share the existing panel toast slot so action errors remain readable during reconnect and catch-up. Verify the interaction and draft retention in the real browser, preserve the tested freeze transition, and refresh the Nix dependency hash for react-freeze.

* test(hub): expose complete creation and message retry failures

Partial response assertions hid the creation error and allowed duplicate responses with incomplete agent data. Check complete success envelopes, the shared agent snapshot, and null message errors so future failures print the actual error payload.

* fix(app): dismiss retained chat menus before freezing

An async fork navigates before its closing state commits, so freezing the source chat could leave its portal backdrop blocking the destination. Remove the shared menu surface in the inactive commit before suspension.

Keep regression assertions aligned with open-chat subscriptions, per-agent incremental recovery, plugin contribution lifetimes, and rendered image completion.

* test(app): expect the reloaded plugin to contribute its pill again

A full reload discards the pressed pill with the page, then evaluates the plugin again and its agents snapshot re-adds the pill. The old count-zero assertion only passed on main because the chat was not ready yet when the URL settled; open chats now subscribe from session start, so the pill is rendered by then.

* fix(app): suspend hidden chats on web only

react-freeze is Suspense with a never-resolving thenable. Suspending a committed subtree hides its host nodes, tears down layout effects, and detaches refs, which changes Fabric ownership of the retained chat. Split the boundary by platform: the web file keeps the two-commit freeze, the native file keeps the chat mounted and live, and the retained-panel signal remains the only thing that stops background work. Restore the mobile-panels rule the branch had rewritten.

* fix(creation): never read a receipt while replacing it

Admission reads the creation receipt while the runner replaces it atomically. On Windows a rename over a file this process still has open for reading fails with EPERM, and that error marked an otherwise completed creation as failed (#4865). Queue reads and writes per receipt so one owner never overlaps them, and serialize the record when the write is queued because the runner mutates it afterwards. No retry on EPERM: the conflict is removed rather than tolerated.

Linux cannot reproduce the rename failure, so the Windows server-tests job (creation/index.test.ts and hub/execution-session.websocket.test.ts) is the verification for this change.
This commit is contained in:
Mohamed Boudra
2026-09-15 04:30:25 +02:00
committed by GitHub
parent 6215e08efe
commit b21c004ffe
46 changed files with 787 additions and 390 deletions
+1 -1
View File
@@ -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.
+4
View File
@@ -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,
+2
View File
@@ -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
+20 -11
View File
@@ -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.
+1
View File
@@ -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",
@@ -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();
@@ -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);
@@ -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.
@@ -20,6 +20,7 @@ import {
interface ViewedTimelineScenario {
client: SeedDaemonClient;
workspaceId: string;
repoPath: string;
firstAgentId: string;
secondAgentId: string;
cleanup(): Promise<void>;
@@ -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<v
await expectTurnCopyButton(page);
}
async function withViewedTimelineScenario(
run: (scenario: ViewedTimelineScenario) => Promise<void>,
) {
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,
});
@@ -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);
}
@@ -849,7 +849,12 @@ export async function installDaemonWebSocketGate(page: Page) {
getClientRequests(type: string): ReadonlyArray<ClientRequest> {
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 {
@@ -92,7 +92,7 @@ export async function measureRelayRestartDuringStream(input: {
agentTitle: string;
}): Promise<RelayDeploymentMeasurements> {
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);
@@ -6,6 +6,7 @@ import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./w
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
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);
}
@@ -95,19 +95,18 @@ export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
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<void> {
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,
});
}
+1
View File
@@ -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",
@@ -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 <MenuSheetSurface {...props} />;
}
+5 -14
View File
@@ -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;
}
@@ -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([]);
});
});
@@ -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<boolean> {
if (input.awayMs < SESSION_STALE_AFTER_MS) {
return false;
}
input.bumpHistorySyncGeneration(input.serverId);
return true;
}
@@ -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);
});
});
@@ -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";
+2 -1
View File
@@ -226,7 +226,8 @@ export const ar: TranslationResources = {
states: {
notFound: "لم يتم العثور على Agent",
failedToLoad: "فشل تحميل الوكيل",
reconnecting: "جارٍ إعادة الاتصال",
reconnecting: "جارٍ إعادة الاتصال بالمضيف",
updating: "جارٍ تحديث الرسائل",
timelineSyncFailed: "تعذر تحديث سجل الوكيل.",
timelineSyncRetrying: "جارٍ إعادة المحاولة…",
archivingTitle: "وكيل الارشيف...",
+2 -1
View File
@@ -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...",
+2 -1
View File
@@ -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...",
+2 -1
View File
@@ -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...",
+2 -1
View File
@@ -227,7 +227,8 @@ export const ja: TranslationResources = {
states: {
notFound: "エージェントが見つかりません",
failedToLoad: "エージェントの読み込みに失敗しました",
reconnecting: "再接続中",
reconnecting: "ホストに再接続中",
updating: "メッセージを更新中",
timelineSyncFailed: "エージェントの履歴を更新できませんでした。",
timelineSyncRetrying: "再試行しています…",
archivingTitle: "エージェントをアーカイブ中...",
+2 -1
View File
@@ -226,7 +226,8 @@ export const ko: TranslationResources = {
states: {
notFound: "에이전트를 찾을 수 없습니다",
failedToLoad: "에이전트를 불러오지 못했습니다",
reconnecting: "다시 연결하는 중",
reconnecting: "호스트에 다시 연결하는 중",
updating: "메시지 업데이트 중",
timelineSyncFailed: "에이전트 기록을 새로고침할 수 없습니다.",
timelineSyncRetrying: "재시도 중…",
archivingTitle: "에이전트 보관 중...",
+2 -1
View File
@@ -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...",
+2 -1
View File
@@ -227,7 +227,8 @@ export const ru: TranslationResources = {
states: {
notFound: "Агент не найден",
failedToLoad: "Не удалось загрузить агента",
reconnecting: "Повторное подключение",
reconnecting: "Повторное подключение к хосту",
updating: "Обновление сообщений",
timelineSyncFailed: "Не удалось обновить историю агента.",
timelineSyncRetrying: "Повторная попытка…",
archivingTitle: "Агент перемещается в архив...",
+2 -1
View File
@@ -226,7 +226,8 @@ export const zhCN: TranslationResources = {
states: {
notFound: "未找到 Agent",
failedToLoad: "加载 Agent 失败",
reconnecting: "正在重连",
reconnecting: "正在重新连接主机",
updating: "正在更新消息",
timelineSyncFailed: "无法刷新代理历史记录。",
timelineSyncRetrying: "正在重试…",
archivingTitle: "正在归档 Agent...",
+17 -103
View File
@@ -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<string, ReconnectToastState>();
interface ChatAgentSelectedState extends ChatAgentStateShape {
archivedAt: Date | null;
requiresAttention: boolean;
@@ -385,13 +374,15 @@ function AgentPanel() {
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<AgentPanelContent
serverId={serverId}
workspaceId={workspaceId}
agentId={target.agentId}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={openFileInWorkspace}
/>
<RetainedChatContent>
<AgentPanelContent
serverId={serverId}
workspaceId={workspaceId}
agentId={target.agentId}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={openFileInWorkspace}
/>
</RetainedChatContent>
);
}
@@ -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<typeof useHostRuntimeClient>;
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<typeof useHostRuntimeClient>;
isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
@@ -768,7 +753,6 @@ function ChatAgentContent({
const streamViewRef = useRef<AgentStreamViewHandle>(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: (
<View
accessible={false}
testID="agent-reconnecting-status-dot"
style={styles.reconnectingStatusDot}
/>
),
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: (
<View
accessible={false}
testID="agent-reconnecting-status-dot"
style={styles.reconnectingStatusDot}
/>
),
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({
</View>
) : null}
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
<TimelineSyncStatus sync={timelineSync} toast={toast} onDismiss={dismiss} />
</DockedChatSurface>
{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",
@@ -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,
});
});
});
@@ -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,
};
}
@@ -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;
}
@@ -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 <Freeze freeze={!active && frozen}>{children}</Freeze>;
}
@@ -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");
+5 -5
View File
@@ -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<void> {
@@ -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);
}
@@ -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<WorkspaceLayoutStore, "layoutByWorkspace">) => {
const ids = new Set<string>();
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(),
+49
View File
@@ -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<ToastState | null>(
() =>
state && label
? {
id: state === "reconnecting" ? 1 : 2,
content: label,
nativeMessage: label,
icon: <ThemedLoadingSpinner size={18} uniProps={spinnerColor} />,
variant: "default",
durationMs: null,
testID: `agent-${state}-toast`,
}
: null,
[state, label],
);
return (
<ToastViewport
toast={toast ?? syncToast}
onDismiss={toast ? onDismiss : keepUntilSynchronized}
placement="panel"
/>
);
}
@@ -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<string>();
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 () => {
@@ -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<string, CatchUpState>();
const catchUpGenerations = new Map<string, number>();
// 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<string, ProjectedTimelineForwardFetchPlan>();
const visibilityCatchUpPending = new Set<string>();
const visibilityCatchUpErrors = new Map<string, string>();
@@ -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();
+87 -4
View File
@@ -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();
+27 -5
View File
@@ -1167,6 +1167,7 @@ export class DaemonClient {
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
private runtimeMetrics: DaemonClientRuntimeMetrics | null = null;
private pingProbe: PingProbe | null = null;
private connectionVerification: DaemonTransport | null = null;
private livenessHeartbeatTimer: ReturnType<typeof setTimeout> | 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;
}
+27 -5
View File
@@ -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<typeof RecordSchema>;
export class CreationService {
private admission: Promise<unknown> = Promise.resolve();
private readonly active = new Map<string, Promise<CreationSnapshot>>();
private readonly receiptOperations = new Map<string, Promise<void>>();
private readonly observers = new Map<string, Set<Observer>>();
constructor(
private readonly directory: string,
@@ -305,11 +306,32 @@ export class CreationService {
this.notify(observer, record.snapshot);
}
private write(identity: string, record: Record): Promise<void> {
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<Record | null> {
const text = await readOptional(join(this.directory, `${identity}.json`));
return text === null ? null : RecordSchema.parse(JSON.parse(text));
private read(identity: string): Promise<Record | null> {
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<T>(identity: string, access: () => Promise<T>): Promise<T> {
// 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 {
@@ -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