Files
paseo/packages/app/e2e/browser/viewed-agent-timelines.spec.ts
T
Mohamed BoudraandElsa Granger cab5eedfe3 Keep workspace layouts usable in narrow columns across platforms (#8740)
* fix(app): retain Explorer resize width until acknowledged

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): choose workspace header by measured column width

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): separate iOS composer clipping from its shadow

Keep explicit coverage of the existing keyboard rotation guard.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): choose workspace chrome within each desktop pane

The outer width wrapper added a compact header while the desktop split container kept its full header and pane rails. Each pane now owns the complete choice, replacing its header and rail together.

Each split pane uses its own measured width: below 500 points it shows its own tab dropdown, even if the neighboring pane is wide. Pane width determines usable header space. Keep new-tab keyboard ownership outside the switched chrome and retain positive measurements while hidden.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): reserve readable width for the compact tab popover

Native scroll content does not contribute intrinsic width to the anchored frame. The count badge alone allowed the popover to compress its tab list and footer labels.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): preserve compact pane actions and window clearance

Carry the owning pane into compact launch actions, keep split and restore controls in its menu, and distribute window corners through visible split children. Keep tab rails below header-owned window controls.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): retain window dragging in compact panes

Keep the shared titlebar drag region when pane width replaces full desktop chrome. Cover two compact panes with both sidebars closed through the desktop host adapter.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): align pane journeys with measured workspace chrome

Give rail-specific split journeys enough column width, assert launched Diff content independently of header shape, and retain pane identity across compact transitions. Expect the compact selector in the half-screen Explorer workflow.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): share pane tab-switcher rendering after header rebase

Keep compact and vertical-bar header actions on the same pane-aware switcher contract while preserving the merged control geometry and script ownership.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): retain Explorer acknowledgements across consecutive drags

Keep earlier released widths as expected allocations while a newer gesture owns the frame, and restore the latest release if that gesture is canceled.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep focus mode exit in the compact workspace menu

Carry the workspace focus state into its compact action menu so a narrow header retains an exit without keyboard shortcuts.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): distinguish resize rollback from pending acknowledgements

Restore the last released width when repeated widths precede a canceled gesture, and retain ownership when an unchanged old allocation matches a newer release before its predecessors arrive.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): allow software WebGL time to finish atlas growth coverage

The 256-batch pixel regression crosses more than 500 render boundaries and exceeds its 30-second total budget under CPU contention. Keep the corpus, every pixel assertion, and the two-second per-frame deadline; allow 60 seconds for the complete workload. Reproduced the timeout with Chromium confined to one CPU and verified two successful constrained runs after the adjustment.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-04 12:23:41 +01:00

459 lines
18 KiB
TypeScript

import { expect, type Page } from "@playwright/test";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { test } from "../support/fixtures";
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { selectWorkspaceInSidebar } from "../support/helpers/sidebar";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
expectAgentIdle,
expectInlineWorkingIndicator,
expectTurnCopyButton,
} from "../support/helpers/agent-stream";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
interface ViewedTimelineScenario {
client: SeedDaemonClient;
workspaceId: string;
repoPath: string;
firstAgentId: string;
secondAgentId: string;
cleanup(): Promise<void>;
}
async function seedViewedTimelineScenario(
options: { firstAgentModel?: string } = {},
): Promise<ViewedTimelineScenario> {
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
const createAgent = (title: string, model = "ten-second-stream") =>
workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model,
});
const [firstAgent, secondAgent] = await Promise.all([
createAgent("First viewed chat", options.firstAgentModel),
createAgent("Second viewed chat"),
]);
return {
client: workspace.client,
workspaceId: workspace.workspaceId,
repoPath: workspace.repoPath,
firstAgentId: firstAgent.id,
secondAgentId: secondAgent.id,
cleanup: workspace.cleanup,
};
}
interface RestoredLayoutChat {
workspaceId: string;
agentId: string;
title: string;
}
interface RestoredLayoutScenario {
chats: RestoredLayoutChat[];
cleanup(): Promise<void>;
}
/**
* Two workspaces with two chats each. Opening all four leaves the persisted workspace layout
* holding a tab per chat, which is the state a relaunch restores into.
*/
async function seedRestoredLayoutScenario(): Promise<RestoredLayoutScenario> {
const workspaces = await Promise.all([
seedWorkspace({ repoPrefix: "restored-layout-first-" }),
seedWorkspace({ repoPrefix: "restored-layout-second-" }),
]);
try {
const chats = await Promise.all(
workspaces.flatMap((workspace, workspaceIndex) =>
[1, 2].map(async (chatIndex): Promise<RestoredLayoutChat> => {
const title = `Restored chat ${workspaceIndex + 1}-${chatIndex}`;
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "ten-second-stream",
});
return { workspaceId: workspace.workspaceId, agentId: agent.id, title };
}),
),
);
return {
chats,
cleanup: async () => {
for (const workspace of workspaces) await workspace.cleanup();
},
};
} catch (error) {
for (const workspace of workspaces) await workspace.cleanup().catch(() => undefined);
throw error;
}
}
async function openAgent(
page: Page,
scenario: { workspaceId: string },
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"),
);
await waitForWorkspaceTabsVisible(page);
}
async function selectAgent(page: Page, title: string) {
await page.getByRole("button", { name: title, exact: true }).click();
}
async function expectForkFailureWithoutOverlappingStatus(page: Page) {
const expectContinuousToast = await observeToastReplacement(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);
await expectContinuousToast();
}
async function observeToastReplacement(page: Page) {
const observation = await page
.getByRole("alert")
.filter({ hasText: "Reconnecting to host" })
.evaluateHandle((toast) => {
const frames: Array<{ connected: boolean; opacity: number; transform: string }> = [];
let frame = 0;
const sample = () => {
const style = getComputedStyle(toast);
frames.push({
connected: toast.isConnected,
opacity: Number(style.opacity),
transform: style.transform,
});
frame = requestAnimationFrame(sample);
};
sample();
return {
stop() {
cancelAnimationFrame(frame);
return frames;
},
};
});
return async () => {
const frames = await observation.evaluate((recorder) => recorder.stop());
await observation.dispose();
expect(frames.length).toBeGreaterThan(1);
expect(frames.every((frame) => frame.connected)).toBe(true);
expect(Math.min(...frames.map((frame) => frame.opacity))).toBe(1);
expect(new Set(frames.map((frame) => frame.transform)).size).toBe(1);
};
}
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" });
});
}
async function moveActiveTabRight(page: Page) {
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
}
async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) {
await scenario.client.sendAgentMessage(agentId, prompt);
const finish = await scenario.client.waitForFinish(agentId, 30_000);
expect(finish.status).toBe("idle");
}
async function startVisibleTurn(
page: Page,
scenario: ViewedTimelineScenario,
prompt: string,
): Promise<void> {
await scenario.client.sendAgentMessage(scenario.firstAgentId, prompt);
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
await expectInlineWorkingIndicator(page);
}
async function expectAgentConsistentlyIdle(page: Page, title: string): Promise<void> {
const tab = page.getByRole("button", { name: title, exact: true });
await expect(tab.locator('[data-status-bucket="running"]')).toHaveCount(0);
await expectAgentIdle(page);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
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("a reloaded layout subscribes only the chat it restores into", async ({ page }) => {
test.setTimeout(120_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedRestoredLayoutScenario();
try {
// One document load, then sidebar and tab clicks — the way a user reaches these chats.
// A `page.goto` per chat would restart the app and empty the session's set each time.
const [first, second, third, fourth] = scenario.chats;
await openAgent(page, first!, first!.agentId);
await selectAgent(page, second!.title);
await selectWorkspaceInSidebar(page, third!.workspaceId);
await selectAgent(page, third!.title);
await selectAgent(page, fourth!.title);
await subscriptions.waitForSubscribedAgents(scenario.chats.map((chat) => chat.agentId));
// Relaunch. Layout comes back from disk carrying a tab for every chat above; only the
// one on screen may be subscribed, because subscribing resumes the agent on the daemon.
const restored = scenario.chats.at(-1)!;
const sibling = scenario.chats.at(-2)!;
subscriptions.reset();
await page.reload();
await waitForWorkspaceTabsVisible(page);
await expect(page.getByRole("button", { name: restored.title, exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("button", { name: sibling.title, exact: true })).toBeVisible();
await subscriptions.waitForSubscribedAgents([restored.agentId]);
// Opening the restored sibling is what adds it, and it adds only itself.
await selectAgent(page, sibling.title);
await subscriptions.waitForSubscribedAgents([restored.agentId, sibling.agentId]);
} finally {
await scenario.cleanup();
}
});
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,
}) => {
test.setTimeout(150_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
try {
await openAgent(page, scenario, scenario.firstAgentId);
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
expect(finish.status).toBe("idle");
await selectAgent(page, "First viewed chat");
await expectAgentConsistentlyIdle(page, "First viewed chat");
} finally {
await scenario.cleanup();
}
});
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, { 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,
"Committed while the first chat is hidden.",
);
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();
}
});
test("two visible split chats both stay current", async ({ page }) => {
// Keep both split panes wide enough for the tab-rail interactions below.
await page.setViewportSize({ width: 1600, height: 900 });
const scenario = await seedViewedTimelineScenario();
try {
await enableMoveTabShortcut(page);
await openAgent(page, scenario, scenario.firstAgentId);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await selectAgent(page, "Second viewed chat");
await moveActiveTabRight(page);
await expect(
page.getByRole("button", { name: "First viewed chat", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Second viewed chat", exact: true }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2);
await commitMessage(scenario, scenario.firstAgentId, "First visible pane update.");
await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "Second viewed chat", exact: true }),
).toBeVisible();
} finally {
await scenario.cleanup();
}
});
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 visible response to observe the connected-but-updating state.
gate.holdTimelineResponses(scenario.firstAgentId);
const expectContinuousToast = await observeToastReplacement(page);
gate.restore();
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 expectContinuousToast();
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,
});
await expect(recoveredMessage).toHaveCount(1);
await expect(recoveredMessage).toBeVisible();
} finally {
gate.restore();
await scenario.cleanup();
}
});
test("preserves reconnecting toast through retained tab switches", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await selectAgent(page, "First viewed chat");
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
await selectAgent(page, "Second viewed chat");
await expectReconnectingToastVisible(page, { timeout: 500 });
} finally {
gate.restore();
await scenario.cleanup();
}
});
});