fix(app): keep hidden chats current through brief switches

This commit is contained in:
Mohamed Boudra
2026-07-21 20:25:50 +02:00
parent c049e86fee
commit a4d11cda2b
5 changed files with 38 additions and 94 deletions

View File

@@ -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 - 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 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. 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. 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 After grace has expired, revisiting a retained timeline displays its cached state immediately and
catch-up completes; cached partial output is never presented as current history. authoritative catch-up advances it to the current tail.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing - Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs. authoritative catch-up, but the app does not issue selective-subscription RPCs.

View File

@@ -7,6 +7,10 @@ interface SessionMessage {
payload?: unknown; payload?: unknown;
} }
interface TimelineSubscriptionWaitOptions {
timeout?: number;
}
function readSessionMessage(message: WebSocketMessage): SessionMessage | null { function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
if (typeof message !== "string") return null; if (typeof message !== "string") return null;
try { try {
@@ -36,72 +40,16 @@ export function observeTimelineSubscriptions(page: Page) {
}); });
return { return {
async waitForSubscribedAgents(agentIds: string[]): Promise<void> { async waitForSubscribedAgents(
agentIds: string[],
options: TimelineSubscriptionWaitOptions = {},
): Promise<void> {
const expected = [...new Set(agentIds)].sort(); const expected = [...new Set(agentIds)].sort();
await expect await expect
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 }) .poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
timeout: options.timeout ?? 15_000,
})
.toEqual(expected); .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<string[]> {
return page.evaluate(
() =>
new Promise<string[]>((resolve) => {
requestAnimationFrame(() => {
const state = (
window as typeof window & { __assistantFrameState?: AssistantFrameState }
).__assistantFrameState;
if (!state) {
resolve([]);
return;
}
state.active = false;
resolve([...state.snapshots]);
});
}),
);
},
};
}

View File

@@ -3,10 +3,7 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { test } from "./fixtures"; import { test } from "./fixtures";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
observeLastAssistantFrames,
observeTimelineSubscriptions,
} from "./helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { import {
@@ -75,47 +72,41 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
} }
test.describe("Viewed agent timelines", () => { test.describe("Viewed agent timelines", () => {
test("a focused turn streams live and resumes its hidden remainder atomically", async ({ test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
page,
}) => {
test.setTimeout(90_000); test.setTimeout(90_000);
const subscriptions = observeTimelineSubscriptions(page); const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario(); const scenario = await seedViewedTimelineScenario();
try { try {
await openAgent(page, scenario, scenario.firstAgentId); await openAgent(page, scenario, scenario.firstAgentId);
await subscriptions.waitForSubscribedAgents([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 selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]); await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000); await commitMessage(
expect(finish.status).toBe("idle"); scenario,
scenario.firstAgentId,
const assistantFrames = await observeLastAssistantFrames(page); "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 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(); 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 { } finally {
await scenario.cleanup(); 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(); const scenario = await seedViewedTimelineScenario();
try { try {
await openAgent(page, scenario, scenario.firstAgentId); await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat"); await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
await commitMessage( await commitMessage(
scenario, scenario,
scenario.firstAgentId, scenario.firstAgentId,
@@ -128,6 +119,7 @@ test.describe("Viewed agent timelines", () => {
await expect( await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }), page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toBeVisible(); ).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
} finally { } finally {
await scenario.cleanup(); await scenario.cleanup();
} }

View File

@@ -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 () => { test("uses a tail fetch when a live cursor is not authoritative", async () => {
const world = new TimelineWorld(); const world = new TimelineWorld();
world.setLiveCursor("agent-a", 9); world.setLiveCursor("agent-a", 9);

View File

@@ -42,7 +42,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
} }
const RETRY_DELAY_MS = 1_000; 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"; type CatchUpStatus = "running" | "complete" | "error";