Keep focused agent timelines live and catch up instantly (#2196)
* fix(app): keep focused agent timelines live Window focus was conflated with app visibility, so switching OS windows could remove the selected timeline subscription. Separate those signals, grace every visibility-driven removal, and cover retained history until authoritative catch-up completes. * fix(app): keep timeline catch-up failures non-blocking * fix(app): surface timeline catch-up failures * fix(app): preserve file preview refocus * fix(app): preserve optimistic catch-up flow
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface SessionMessage {
|
||||
type?: unknown;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: SessionMessage;
|
||||
};
|
||||
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function observeTimelineSubscriptions(page: Page) {
|
||||
let acknowledgedAgentIds: string[] | null = null;
|
||||
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
const message = readSessionMessage(payload);
|
||||
if (message?.type !== "agent.timeline.set_subscription.response") return;
|
||||
const response = message.payload as { agentIds?: unknown } | undefined;
|
||||
if (!Array.isArray(response?.agentIds)) return;
|
||||
acknowledgedAgentIds = response.agentIds.filter(
|
||||
(agentId): agentId is string => typeof agentId === "string",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
|
||||
const expected = [...new Set(agentIds)].sort();
|
||||
await expect
|
||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { 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<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]);
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { getServerId } from "./helpers/server-id";
|
||||
*
|
||||
* The repro is the mundane user flow: with a workspace already showing a terminal, open another
|
||||
* one and switch to a different app while it spawns. We stage the blur deterministically by
|
||||
* stubbing `document.hasFocus()` (which `useAppVisible` consults on window focus/blur events)
|
||||
* stubbing `document.hasFocus()` (which `useAppActivelyVisible` consults on focus/blur events)
|
||||
* instead of racing real OS focus.
|
||||
*
|
||||
* The assertion reads the PTY's own opinion of its size (`stty size`) with input written
|
||||
@@ -23,7 +23,7 @@ import { getServerId } from "./helpers/server-id";
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simulates the window losing/regaining OS focus, which is what `useAppVisible` reads through
|
||||
* Simulates the window losing/regaining OS focus, which is what `useAppActivelyVisible` reads through
|
||||
* `document.hasFocus()` plus the window focus/blur events.
|
||||
*
|
||||
* This has to be stubbed: headless Chromium never actually blurs. Opening a second page and
|
||||
|
||||
@@ -3,6 +3,10 @@ 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 { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import {
|
||||
@@ -71,6 +75,42 @@ 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.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 selectAgent(page, "First viewed chat");
|
||||
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 }) => {
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user