mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
Merge remote-tracking branch 'origin/main' into ci/simplify-test-suite
# Conflicts: # packages/client/src/daemon-client.ts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Generated
+1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -226,7 +226,8 @@ export const ar: TranslationResources = {
|
||||
states: {
|
||||
notFound: "لم يتم العثور على Agent",
|
||||
failedToLoad: "فشل تحميل الوكيل",
|
||||
reconnecting: "جارٍ إعادة الاتصال",
|
||||
reconnecting: "جارٍ إعادة الاتصال بالمضيف",
|
||||
updating: "جارٍ تحديث الرسائل",
|
||||
timelineSyncFailed: "تعذر تحديث سجل الوكيل.",
|
||||
timelineSyncRetrying: "جارٍ إعادة المحاولة…",
|
||||
archivingTitle: "وكيل الارشيف...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -227,7 +227,8 @@ export const ja: TranslationResources = {
|
||||
states: {
|
||||
notFound: "エージェントが見つかりません",
|
||||
failedToLoad: "エージェントの読み込みに失敗しました",
|
||||
reconnecting: "再接続中",
|
||||
reconnecting: "ホストに再接続中",
|
||||
updating: "メッセージを更新中",
|
||||
timelineSyncFailed: "エージェントの履歴を更新できませんでした。",
|
||||
timelineSyncRetrying: "再試行しています…",
|
||||
archivingTitle: "エージェントをアーカイブ中...",
|
||||
|
||||
@@ -226,7 +226,8 @@ export const ko: TranslationResources = {
|
||||
states: {
|
||||
notFound: "에이전트를 찾을 수 없습니다",
|
||||
failedToLoad: "에이전트를 불러오지 못했습니다",
|
||||
reconnecting: "다시 연결하는 중",
|
||||
reconnecting: "호스트에 다시 연결하는 중",
|
||||
updating: "메시지 업데이트 중",
|
||||
timelineSyncFailed: "에이전트 기록을 새로고침할 수 없습니다.",
|
||||
timelineSyncRetrying: "재시도 중…",
|
||||
archivingTitle: "에이전트 보관 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -227,7 +227,8 @@ export const ru: TranslationResources = {
|
||||
states: {
|
||||
notFound: "Агент не найден",
|
||||
failedToLoad: "Не удалось загрузить агента",
|
||||
reconnecting: "Повторное подключение",
|
||||
reconnecting: "Повторное подключение к хосту",
|
||||
updating: "Обновление сообщений",
|
||||
timelineSyncFailed: "Не удалось обновить историю агента.",
|
||||
timelineSyncRetrying: "Повторная попытка…",
|
||||
archivingTitle: "Агент перемещается в архив...",
|
||||
|
||||
@@ -226,7 +226,8 @@ export const zhCN: TranslationResources = {
|
||||
states: {
|
||||
notFound: "未找到 Agent",
|
||||
failedToLoad: "加载 Agent 失败",
|
||||
reconnecting: "正在重连",
|
||||
reconnecting: "正在重新连接主机",
|
||||
updating: "正在更新消息",
|
||||
timelineSyncFailed: "无法刷新代理历史记录。",
|
||||
timelineSyncRetrying: "正在重试…",
|
||||
archivingTitle: "正在归档 Agent...",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
@@ -1760,6 +1771,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();
|
||||
|
||||
@@ -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;
|
||||
@@ -1457,19 +1458,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.connectPromise) {
|
||||
this.attemptConnect();
|
||||
return;
|
||||
@@ -1477,6 +1477,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user