Files
paseo/packages/app/e2e/support/helpers/chat-outline.ts
T
paseo-bot[bot] 68e722ec2e fix(app): request a chat outline only for a timeline the daemon has served (#5602)
A new agent's draft pane rendered the chat outline with its local tab or
draft id, so the web app asked the daemon for that id's prompt index before
the agent existed and the daemon logged Agent not found. The outline now
waits until the viewed timeline has an epoch, which only a daemon-served
agent timeline has.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 06:16:30 +02:00

252 lines
9.3 KiB
TypeScript

import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { loadSessionMessageReaders } from "./new-workspace";
export async function withStreamingMarkdownOutline(
run: (agent: MockAgentWorkspace) => Promise<void>,
): Promise<void> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-outline-markdown-",
title: "Streaming Markdown outline",
featureValues: {
mockStreamingAssistantResponse: Array.from(
{ length: 60 },
(_, index) => `Paragraph ${index + 1}.`,
).join("\n\n"),
mockStreamingAssistantIntervalMs: 80,
},
});
try {
await run(agent);
} finally {
await agent.cleanup();
}
}
export async function expectReadingStreamedMarkdown(page: Page, prompt: string): Promise<void> {
await expect(page.getByText("Paragraph 30.", { exact: true }).last()).toBeVisible();
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.getByTestId("user-message").filter({ hasText: prompt });
await expect
.poll(async () => {
const [viewport, row] = await Promise.all([timeline.boundingBox(), promptRow.boundingBox()]);
return viewport && row ? row.y + row.height - viewport.y : Number.POSITIVE_INFINITY;
})
.toBeLessThan(0);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
}
export function chatOutlineRail(page: Page): Locator {
return page.getByTestId("chat-outline-rail");
}
function chatOutlinePrompt(page: Page, position: number): Locator {
return chatOutlineRail(page)
.getByRole("tab")
.nth(position - 1);
}
export async function expectChatOutlinePrompts(page: Page, count: number): Promise<void> {
await expect(chatOutlineRail(page)).toBeVisible();
await expect(chatOutlineRail(page).getByRole("tab")).toHaveCount(count);
}
export async function expectNoChatOutline(page: Page): Promise<void> {
await expect(chatOutlineRail(page)).toBeHidden();
}
export async function hoverChatOutlinePrompt(page: Page, position: number): Promise<void> {
await chatOutlinePrompt(page, position).hover();
}
/**
* The trailing edge of a prompt's row, far from its left-anchored pill. Acquiring a prompt
* there is what makes the rail usable when a long conversation squeezes the pills thin.
*/
export async function pointAtChatOutlineRowEdge(page: Page, position: number): Promise<void> {
const row = await promptRowBox(page, position);
await page.mouse.move(row.x + row.width - 1, row.y + row.height / 2);
}
export async function clickChatOutlineRowEdge(page: Page, position: number): Promise<void> {
await pointAtChatOutlineRowEdge(page, position);
await page.mouse.down();
await page.mouse.up();
}
export async function splitCurrentPanelRight(page: Page): Promise<void> {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
}
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
const timelineUrl = page.url();
await openSettings(page);
await openSettingsSection(page, "chat");
await page.getByRole("switch", { name: "Chat outline" }).click();
await page.goto(timelineUrl);
}
/** Parks the pointer in the middle of the transcript, clear of the rail. */
export async function movePointerOffChatOutline(page: Page): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const box = await requireBoundingBox(timeline);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
}
export async function focusChatOutlinePrompt(page: Page, position: number): Promise<void> {
await chatOutlinePrompt(page, position).focus();
}
export async function pressEnterOnFocusedPrompt(page: Page): Promise<void> {
await page.keyboard.press("Enter");
}
export async function expectChatOutlinePreview(page: Page, preview: string): Promise<void> {
await expect(page.getByTestId("chat-outline-preview")).toHaveText(preview);
}
export async function expectNoChatOutlinePreview(page: Page): Promise<void> {
await expect(page.getByTestId("chat-outline-preview")).toHaveCount(0);
}
export async function expectNoChatOutlinePreviewWhileCrossingToSidebar(page: Page): Promise<void> {
const railBox = await requireBoundingBox(chatOutlineRail(page));
const clockStart = Date.now();
await page.clock.install({ time: clockStart });
await page.clock.pauseAt(clockStart + 60_000);
await page.mouse.move(railBox.x + railBox.width + 2, railBox.y + railBox.height / 2);
await page.evaluate(() => {
document.body.dataset.chatOutlinePreviewObserved = String(
document.querySelector('[data-testid="chat-outline-preview"]') !== null,
);
const observer = new MutationObserver(() => {
if (document.querySelector('[data-testid="chat-outline-preview"]')) {
document.body.dataset.chatOutlinePreviewObserved = "true";
}
});
observer.observe(document.body, { childList: true, subtree: true });
Object.assign(window, { __chatOutlinePreviewObserver: observer });
});
// Keep the transit slower than the activation delay at every point while making the whole
// crossing longer than it. Horizontal motion must keep postponing activation until leave
// cancels the final pending timer.
const transitSteps = 6;
for (let step = 0; step < transitSteps; step += 1) {
await page.mouse.move(
railBox.x + railBox.width - 1 - (step * railBox.width) / transitSteps,
railBox.y + railBox.height / 2,
);
await page.clock.runFor(100);
}
await page.mouse.move(railBox.x - 2, railBox.y + railBox.height / 2);
await page.clock.runFor(200);
const previewAppeared = await page.evaluate(() => {
const observer = Reflect.get(window, "__chatOutlinePreviewObserver") as
| MutationObserver
| undefined;
observer?.disconnect();
Reflect.deleteProperty(window, "__chatOutlinePreviewObserver");
const observed = document.body.dataset.chatOutlinePreviewObserved === "true";
delete document.body.dataset.chatOutlinePreviewObserved;
return observed;
});
expect(previewAppeared).toBe(false);
}
export async function expectChatOutlinePromptToRemainBare(
page: Page,
position: number,
): Promise<void> {
await expect(chatOutlinePrompt(page, position)).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
}
/** Exactly one prompt is marked, without saying which — the reader always has a "you are here". */
export async function expectOneActiveChatOutlinePrompt(page: Page): Promise<void> {
await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveCount(1);
}
export async function expectActiveChatOutlinePrompt(page: Page, position: number): Promise<void> {
await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveAccessibleName(
new RegExp(`^${position} of `),
);
}
export async function expectActiveChatOutlinePromptMovedFrom(
page: Page,
position: number,
): Promise<void> {
const activePrompt = chatOutlineRail(page).getByRole("tab", { selected: true });
await expect(activePrompt).toHaveCount(1);
await expect(activePrompt).not.toHaveAccessibleName(new RegExp(`^${position} of `));
}
export async function expectLiveTurnPromptAboveFoldAndActive(
page: Page,
prompt: string,
position: number,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.getByTestId("user-message").filter({ hasText: prompt });
const activeTick = chatOutlineRail(page).getByRole("tab", { selected: true });
await expect
.poll(
async () => {
const [timelineBox, promptBox, activeLabel] = await Promise.all([
timeline.boundingBox(),
promptRow.boundingBox(),
activeTick.getAttribute("aria-label"),
]);
return Boolean(
timelineBox &&
promptBox &&
promptBox.y + promptBox.height < timelineBox.y &&
activeLabel?.startsWith(`${position} of `),
);
},
{ timeout: 15_000 },
)
.toBe(true);
}
async function promptRowBox(
page: Page,
position: number,
): Promise<{ x: number; y: number; width: number; height: number }> {
return requireBoundingBox(chatOutlinePrompt(page, position));
}
async function requireBoundingBox(
locator: Locator,
): Promise<{ x: number; y: number; width: number; height: number }> {
await expect(locator).toBeVisible();
const box = await locator.boundingBox();
if (!box) {
throw new Error("Expected the chat outline element to have a layout box");
}
return box;
}
export async function observePromptIndexRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const agentIds: string[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (request?.type === "agent.timeline.list_prompts.request") {
agentIds.push(request.agentId);
}
});
});
return {
async waitForRequestFor(agentId: string) {
await expect.poll(() => agentIds).toContain(agentId);
},
requestedAgentIds: () => [...new Set(agentIds)],
};
}