diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 79e85d0b8..2beddbb96 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -67,11 +67,11 @@ The app chooses one delivery policy from `server_info.features.selectiveAgentTim - Selective daemons receive the union of agents visible in every pane. Additions subscribe and catch up immediately. Every visibility-driven removal, including app backgrounding, stays - subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly + subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible. Disconnecting and disposal clear pending grace because the subscription itself no longer exists. - After grace has expired, a retained timeline stays covered when revisited until authoritative - catch-up completes; cached partial output is never presented as current history. + After grace has expired, revisiting a retained timeline displays its cached state immediately and + authoritative catch-up advances it to the current tail. - Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing authoritative catch-up, but the app does not issue selective-subscription RPCs. diff --git a/packages/app/e2e/helpers/timeline-delivery.ts b/packages/app/e2e/helpers/timeline-delivery.ts index 1ac692b56..b84331852 100644 --- a/packages/app/e2e/helpers/timeline-delivery.ts +++ b/packages/app/e2e/helpers/timeline-delivery.ts @@ -7,6 +7,10 @@ interface SessionMessage { payload?: unknown; } +interface TimelineSubscriptionWaitOptions { + timeout?: number; +} + function readSessionMessage(message: WebSocketMessage): SessionMessage | null { if (typeof message !== "string") return null; try { @@ -36,72 +40,16 @@ export function observeTimelineSubscriptions(page: Page) { }); return { - async waitForSubscribedAgents(agentIds: string[]): Promise { + async waitForSubscribedAgents( + agentIds: string[], + options: TimelineSubscriptionWaitOptions = {}, + ): Promise { const expected = [...new Set(agentIds)].sort(); await expect - .poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 }) + .poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { + timeout: options.timeout ?? 15_000, + }) .toEqual(expected); }, }; } - -interface AssistantFrameState { - active: boolean; - lastText: string | null; - snapshots: string[]; -} - -export async function observeLastAssistantFrames(page: Page) { - await page.evaluate(() => { - const state: AssistantFrameState = { - active: true, - lastText: null, - snapshots: [], - }; - const sample = () => { - const hasPaintedHistoryOverlay = Array.from( - document.querySelectorAll('[data-testid="agent-history-overlay"]'), - ).some((element) => { - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }); - if (!hasPaintedHistoryOverlay) { - const messages = Array.from( - document.querySelectorAll('[data-testid="assistant-message"]'), - ).filter((element) => { - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }); - const text = messages.at(-1)?.textContent?.trim() ?? ""; - if (text && text !== state.lastText) { - state.lastText = text; - state.snapshots.push(text); - } - } - if (state.active) requestAnimationFrame(sample); - }; - Object.assign(window, { __assistantFrameState: state }); - requestAnimationFrame(sample); - }); - - return { - async stop(): Promise { - return page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => { - const state = ( - window as typeof window & { __assistantFrameState?: AssistantFrameState } - ).__assistantFrameState; - if (!state) { - resolve([]); - return; - } - state.active = false; - resolve([...state.snapshots]); - }); - }), - ); - }, - }; -} diff --git a/packages/app/e2e/viewed-agent-timelines.spec.ts b/packages/app/e2e/viewed-agent-timelines.spec.ts index 67f5b3289..385dc5486 100644 --- a/packages/app/e2e/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/viewed-agent-timelines.spec.ts @@ -3,10 +3,7 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { test } from "./fixtures"; import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; -import { - observeLastAssistantFrames, - observeTimelineSubscriptions, -} from "./helpers/timeline-delivery"; +import { observeTimelineSubscriptions } from "./helpers/timeline-delivery"; import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; import { @@ -75,47 +72,41 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, } test.describe("Viewed agent timelines", () => { - test("a focused turn streams live and resumes its hidden remainder atomically", async ({ - page, - }) => { + test("an unsubscribed hidden chat catches up when shown", async ({ page }) => { test.setTimeout(90_000); const subscriptions = observeTimelineSubscriptions(page); const scenario = await seedViewedTimelineScenario(); try { await openAgent(page, scenario, scenario.firstAgentId); await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]); - - await scenario.client.sendAgentMessage( - scenario.firstAgentId, - "Stream while focused, then finish while hidden.", - ); - await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible(); - await expect( - page.getByTestId("assistant-message").filter({ hasText: "Cycle 1" }).first(), - ).toBeVisible(); - await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0); - await selectAgent(page, "Second viewed chat"); - await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]); - const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000); - expect(finish.status).toBe("idle"); - - const assistantFrames = await observeLastAssistantFrames(page); + await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 }); + await commitMessage( + scenario, + scenario.firstAgentId, + "Committed after the first chat unsubscribed.", + ); + await expect( + page.getByText("Committed after the first chat unsubscribed.", { exact: true }), + ).toHaveCount(0); await selectAgent(page, "First viewed chat"); + await expect( + page.getByText("Committed after the first chat unsubscribed.", { exact: true }), + ).toBeVisible(); await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); - const snapshots = await assistantFrames.stop(); - - expect(snapshots[0]).toContain("(end of synthetic stream)"); } finally { await scenario.cleanup(); } }); - test("a hidden retained chat catches up when shown", async ({ page }) => { + test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => { + test.setTimeout(60_000); + const subscriptions = observeTimelineSubscriptions(page); const scenario = await seedViewedTimelineScenario(); try { await openAgent(page, scenario, scenario.firstAgentId); await selectAgent(page, "Second viewed chat"); + await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]); await commitMessage( scenario, scenario.firstAgentId, @@ -128,6 +119,7 @@ test.describe("Viewed agent timelines", () => { 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(); } finally { await scenario.cleanup(); } diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index d600a98d0..307680aa3 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -174,6 +174,10 @@ class TimelineWorld { } } +test("keeps hidden agents subscribed for thirty seconds", () => { + expect(VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS).toBe(30_000); +}); + test("uses a tail fetch when a live cursor is not authoritative", async () => { const world = new TimelineWorld(); world.setLiveCursor("agent-a", 9); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index d928d032c..a7151b4e7 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -42,7 +42,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge { } const RETRY_DELAY_MS = 1_000; -export const VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS = 5_000; +export const VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS = 30_000; type CatchUpStatus = "running" | "complete" | "error";