mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Lock timeline layout stability with user-driven specs
Two reds reproduce the reported jank: - new agent, first prompt: the row paints at top=100 then jumps to 144 through authoritative hydration, because the draft tab and the agent panel each render their own AgentStreamView for the same logical row. - long loaded timeline, submit: scrollHeight collapses 10793 -> 9111 mid-turn, because splitOrderedTail reclassifies measured rows into the virtualized segment where the virtualizer falls back to estimateStreamItemHeight. Also restores centered timeline rows: the pagination anchor wrapper introduced in #2490 was a plain block div, which broke alignSelf: center.
This commit is contained in:
271
packages/app/e2e/agent-consecutive-turns.spec.ts
Normal file
271
packages/app/e2e/agent-consecutive-turns.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test as baseTest } from "./fixtures";
|
||||
import { expectAgentIdle } from "./helpers/agent-stream";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
openAgentTimeline,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
seedLongMockAgentTimeline,
|
||||
} from "./helpers/timeline-pagination";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
|
||||
interface TurnFrame {
|
||||
scrollHeight: number;
|
||||
workingVisible: boolean;
|
||||
promptVisible: boolean;
|
||||
promptTop: number | null;
|
||||
assistantVisible: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__consecutiveTurnFrames?: { active: boolean; frames: TurnFrame[] };
|
||||
}
|
||||
}
|
||||
|
||||
const test = baseTest.extend<{ tenSecondAgent: MockAgentWorkspace }>({
|
||||
tenSecondAgent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "consecutive-ten-second-turns-",
|
||||
title: "Consecutive ten-second turns",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await provide(agent);
|
||||
await agent.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function waitForTurnToComplete(page: Page, completedTurnCount: number): Promise<void> {
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(
|
||||
completedTurnCount + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expectAgentIdle(page);
|
||||
}
|
||||
|
||||
async function recordTurnFrames(page: Page, prompt: string): Promise<void> {
|
||||
await page.evaluate((promptText) => {
|
||||
const isVisible = (candidate: Element) => candidate.getBoundingClientRect().height > 0;
|
||||
// The timeline does not exist yet when a new agent is being created, so it is
|
||||
// resolved per frame rather than captured up front.
|
||||
const findScroll = () =>
|
||||
Array.from(document.querySelectorAll('[data-testid="agent-chat-scroll"]')).find(isVisible);
|
||||
const state = { active: true, frames: [] as TurnFrame[] };
|
||||
const sample = () => {
|
||||
const scroll = findScroll();
|
||||
// Re-find the row every frame: reconciliation replaces the node, and a stale
|
||||
// reference would keep reporting the position of something already detached.
|
||||
const prompt = Array.from(document.querySelectorAll('[data-testid="user-message"]')).find(
|
||||
(candidate) => isVisible(candidate) && candidate.textContent?.includes(promptText),
|
||||
);
|
||||
state.frames.push({
|
||||
scrollHeight: scroll ? scroll.scrollHeight : 0,
|
||||
workingVisible: Array.from(
|
||||
document.querySelectorAll('[data-testid="turn-working-indicator"]'),
|
||||
).some(isVisible),
|
||||
promptVisible: Boolean(prompt),
|
||||
promptTop: prompt ? prompt.getBoundingClientRect().top : null,
|
||||
assistantVisible: Array.from(
|
||||
document.querySelectorAll('[data-testid="assistant-message"]'),
|
||||
).some(isVisible),
|
||||
});
|
||||
if (state.active) requestAnimationFrame(sample);
|
||||
};
|
||||
window.__consecutiveTurnFrames = state;
|
||||
sample();
|
||||
}, prompt);
|
||||
}
|
||||
|
||||
async function expectUninterruptedTurn(page: Page): Promise<void> {
|
||||
const result = await page.evaluate(() => {
|
||||
const state = window.__consecutiveTurnFrames;
|
||||
if (!state) throw new Error("Turn frames were never recorded");
|
||||
state.active = false;
|
||||
const start = state.frames.findIndex((frame) => frame.promptVisible);
|
||||
// The turn ends when the working indicator goes away for good. Anything before
|
||||
// that is a blink: the footer left the layout and came back.
|
||||
const lastWorking = state.frames.reduce(
|
||||
(latest, frame, index) => (frame.workingVisible ? index : latest),
|
||||
-1,
|
||||
);
|
||||
const turn = state.frames.slice(start, lastWorking + 1);
|
||||
const blinkAt = turn.findIndex((frame) => !frame.workingVisible);
|
||||
let largestHeightDrop = 0;
|
||||
let largestHeightDropAt = -1;
|
||||
for (let index = 1; index < turn.length; index += 1) {
|
||||
const drop = turn[index - 1].scrollHeight - turn[index].scrollHeight;
|
||||
if (drop > largestHeightDrop) {
|
||||
largestHeightDrop = drop;
|
||||
largestHeightDropAt = index;
|
||||
}
|
||||
}
|
||||
return {
|
||||
start,
|
||||
blinkAt,
|
||||
largestHeightDrop,
|
||||
turnFrameCount: turn.length,
|
||||
largestHeightDropAt,
|
||||
heightsAroundDrop: turn
|
||||
.slice(Math.max(0, largestHeightDropAt - 6), largestHeightDropAt + 6)
|
||||
.map((frame) => frame.scrollHeight)
|
||||
.join(","),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.start, "the submitted prompt never appeared").toBeGreaterThan(-1);
|
||||
expect(
|
||||
result.blinkAt,
|
||||
`the working indicator blinked off at frame ${result.blinkAt} of ${result.turnFrameCount} and came back`,
|
||||
).toBe(-1);
|
||||
expect(
|
||||
result.largestHeightDrop,
|
||||
`the timeline lost ${result.largestHeightDrop}px of content mid-turn (first heights: ${result.heightsAroundDrop})`,
|
||||
).toBe(0);
|
||||
}
|
||||
|
||||
async function expectSubmittedPromptNeverMovedDown(page: Page): Promise<void> {
|
||||
const result = await page.evaluate(() => {
|
||||
const state = window.__consecutiveTurnFrames;
|
||||
if (!state) throw new Error("Turn frames were never recorded");
|
||||
state.active = false;
|
||||
const start = state.frames.findIndex((frame) => frame.promptVisible);
|
||||
const tops = state.frames
|
||||
.slice(start)
|
||||
.map((frame) => frame.promptTop)
|
||||
.filter((top): top is number => top !== null);
|
||||
let largestDownwardShift = 0;
|
||||
let largestDownwardShiftAt = -1;
|
||||
for (let index = 1; index < tops.length; index += 1) {
|
||||
const shift = tops[index] - tops[index - 1];
|
||||
if (shift > largestDownwardShift) {
|
||||
largestDownwardShift = shift;
|
||||
largestDownwardShiftAt = index;
|
||||
}
|
||||
}
|
||||
return {
|
||||
start,
|
||||
largestDownwardShift,
|
||||
topsAroundShift: tops
|
||||
.slice(Math.max(0, largestDownwardShiftAt - 4), largestDownwardShiftAt + 4)
|
||||
.map((top) => Math.round(top))
|
||||
.join(","),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.start, "the submitted prompt never appeared").toBeGreaterThan(-1);
|
||||
expect(
|
||||
Math.round(result.largestDownwardShift),
|
||||
`the submitted prompt was pushed down ${Math.round(result.largestDownwardShift)}px (tops: ${result.topsAroundShift})`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
test("keeps the first submitted prompt in place once the assistant starts streaming", async ({
|
||||
page,
|
||||
tenSecondAgent,
|
||||
}) => {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: tenSecondAgent.workspaceId,
|
||||
agentId: tenSecondAgent.agentId,
|
||||
});
|
||||
|
||||
const prompt = "First prompt.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await recordTurnFrames(page, prompt);
|
||||
await composer.press("Enter");
|
||||
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await waitForTurnToComplete(page, 0);
|
||||
await expectSubmittedPromptNeverMovedDown(page);
|
||||
});
|
||||
|
||||
// The first prompt of a brand new agent is submitted before the agent exists, so the
|
||||
// authoritative timeline arrives after the row is already on screen. That is the only
|
||||
// path where hydration can move an already-visible message.
|
||||
test("keeps the first prompt of a new agent in place through authoritative hydration", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "new-agent-first-prompt-" });
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:create-agent-preferences",
|
||||
JSON.stringify({ provider: "mock", providerPreferences: { mock: { mode: "load-test" } } }),
|
||||
);
|
||||
});
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "First prompt of a new agent.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await recordTurnFrames(page, prompt);
|
||||
await composer.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: prompt }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("turn-working-elapsed")).toHaveCount(1);
|
||||
await expectSubmittedPromptNeverMovedDown(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the turn footer and timeline height stable while a second turn runs", async ({
|
||||
page,
|
||||
tenSecondAgent,
|
||||
}) => {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: tenSecondAgent.workspaceId,
|
||||
agentId: tenSecondAgent.agentId,
|
||||
});
|
||||
|
||||
await submitMessage(page, "First prompt.");
|
||||
await waitForTurnToComplete(page, 0);
|
||||
|
||||
const secondPrompt = "Second prompt.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(secondPrompt);
|
||||
await recordTurnFrames(page, secondPrompt);
|
||||
await composer.press("Enter");
|
||||
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
|
||||
await waitForTurnToComplete(page, 1);
|
||||
await expectUninterruptedTurn(page);
|
||||
});
|
||||
|
||||
test("keeps the timeline height stable when submitting into a long timeline", async ({ page }) => {
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expect(page.getByText(agent.newestPrompt, { exact: true })).toBeVisible();
|
||||
// A real long chat is fully loaded and virtualized before the user types again.
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
|
||||
const prompt = "Stability probe into a long timeline.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await recordTurnFrames(page, prompt);
|
||||
await composer.press("Enter");
|
||||
|
||||
await expect(page.getByText(prompt, { exact: true }).last()).toBeVisible();
|
||||
// Observe the whole turn: wait for it to start before waiting for it to end.
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
await expectAgentIdle(page, 60_000);
|
||||
await expectUninterruptedTurn(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
startRunningMockAgent,
|
||||
} from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { readScrollMetrics } from "./helpers/agent-bottom-anchor";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
@@ -21,6 +20,17 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { selectModel } from "./helpers/app";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
} from "./helpers/timeline-pagination";
|
||||
import {
|
||||
appendSettledTimelineTurns,
|
||||
createSettledMockAgent,
|
||||
createSmallAssistantPng,
|
||||
emitSettledAssistantImage,
|
||||
expectAssistantImageRendered,
|
||||
} from "./helpers/assistant-images";
|
||||
|
||||
const IMAGE = {
|
||||
name: "message-submission.png",
|
||||
@@ -31,15 +41,8 @@ const IMAGE = {
|
||||
),
|
||||
};
|
||||
|
||||
interface MessageGeometry {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface SubmissionScenario {
|
||||
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
|
||||
existingPrompt: string;
|
||||
}
|
||||
|
||||
interface DraftCreateScenario {
|
||||
@@ -63,17 +66,32 @@ const test = baseTest.extend<{
|
||||
unrelatedRunningScenario: UnrelatedRunningScenario;
|
||||
}>({
|
||||
submissionScenario: async ({ page }, provide, testInfo) => {
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `message-submission-${testInfo.workerIndex}-`,
|
||||
title: "Message submission regression",
|
||||
model: "ten-second-stream",
|
||||
await page.setViewportSize({ width: 1280, height: 960 });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: `message-submission-layout-${testInfo.workerIndex}-`,
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
await provide({ gate });
|
||||
await agent.cleanup();
|
||||
try {
|
||||
const agent = await createSettledMockAgent(workspace, "Message submission layout regression");
|
||||
await appendSettledTimelineTurns(workspace.client, agent, 80);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Existing timeline image",
|
||||
fileName: "existing-timeline.png",
|
||||
});
|
||||
await emitSettledAssistantImage(workspace.client, agent, image);
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
const oldestPrompt = "image-history-turn-0: emit 1 coalesced agent stream updates";
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, oldestPrompt);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await provide({
|
||||
existingPrompt:
|
||||
"Emit settled assistant image Markdown: ",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
},
|
||||
draftCreateScenario: async ({ page }, provide, testInfo) => {
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
@@ -174,55 +192,51 @@ async function expectPendingSubmission(page: Page, userMessage: Locator): Promis
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function readMessageGeometry(page: Page, userMessage: Locator): Promise<MessageGeometry> {
|
||||
const box = await userMessage.boundingBox();
|
||||
if (!box) throw new Error("Submitted user message has no browser geometry");
|
||||
const { offsetY } = await readScrollMetrics(page);
|
||||
return { x: box.x, y: box.y + offsetY, width: box.width, height: box.height };
|
||||
}
|
||||
|
||||
async function beginWorkingFooterContinuityCheck(page: Page): Promise<() => Promise<void>> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
const state = { active: true, sawMissing: false };
|
||||
async function beginTimelineRowStabilityCheck(
|
||||
page: Page,
|
||||
timelineRow: Locator,
|
||||
): Promise<() => Promise<void>> {
|
||||
await expect(timelineRow).toBeVisible();
|
||||
await timelineRow.evaluate((initialRow) => {
|
||||
const state = {
|
||||
active: true,
|
||||
lastTop: initialRow.getBoundingClientRect().top,
|
||||
largestDownwardShift: 0,
|
||||
sawMissing: false,
|
||||
};
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
windowState.__messageSubmissionFooterContinuity = state;
|
||||
windowState.__messageSubmissionTimelineRowStability = state;
|
||||
const checkFrame = () => {
|
||||
if (!state.active) return;
|
||||
if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
|
||||
if (!initialRow.isConnected) {
|
||||
state.sawMissing = true;
|
||||
requestAnimationFrame(checkFrame);
|
||||
return;
|
||||
}
|
||||
const top = initialRow.getBoundingClientRect().top;
|
||||
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
|
||||
state.lastTop = top;
|
||||
requestAnimationFrame(checkFrame);
|
||||
};
|
||||
requestAnimationFrame(checkFrame);
|
||||
});
|
||||
|
||||
return async () => {
|
||||
const sawMissing = await page.evaluate(() => {
|
||||
const result = await page.evaluate(() => {
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
const state = windowState.__messageSubmissionFooterContinuity as
|
||||
| { active: boolean; sawMissing: boolean }
|
||||
const state = windowState.__messageSubmissionTimelineRowStability as
|
||||
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
|
||||
| undefined;
|
||||
if (!state) throw new Error("Working-footer continuity check was not started");
|
||||
if (!state) throw new Error("Timeline-row stability check was not started");
|
||||
state.active = false;
|
||||
delete windowState.__messageSubmissionFooterContinuity;
|
||||
return state.sawMissing;
|
||||
delete windowState.__messageSubmissionTimelineRowStability;
|
||||
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
|
||||
});
|
||||
expect(sawMissing).toBe(false);
|
||||
expect(result.sawMissing).toBe(false);
|
||||
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
|
||||
};
|
||||
}
|
||||
|
||||
async function expectAcceptedSubmission(
|
||||
page: Page,
|
||||
userMessage: Locator,
|
||||
submittedGeometry: MessageGeometry,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
expect(await readMessageGeometry(page, userMessage)).toEqual(submittedGeometry);
|
||||
}
|
||||
|
||||
async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise<void> {
|
||||
await attachImageFromMenu(page, IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
@@ -566,18 +580,33 @@ async function completeDraftCreateSubmission(
|
||||
}
|
||||
|
||||
test.describe("Agent message submission", () => {
|
||||
test("keeps the submitted row stable when the host accepts", async ({
|
||||
test("keeps layout stable when submitting to an agent with existing history", async ({
|
||||
page,
|
||||
submissionScenario,
|
||||
}) => {
|
||||
const userMessage = await submitMessageWithImage(page, "Hold this submission.");
|
||||
await expectPendingSubmission(page, userMessage);
|
||||
await submissionScenario.gate.waitForRequest();
|
||||
const submittedGeometry = await readMessageGeometry(page, userMessage);
|
||||
const finishFooterContinuityCheck = await beginWorkingFooterContinuityCheck(page);
|
||||
submissionScenario.gate.accept();
|
||||
await expectAcceptedSubmission(page, userMessage, submittedGeometry);
|
||||
await finishFooterContinuityCheck();
|
||||
const prompt = "Hold this submission.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await expect(composer).toHaveValue(prompt);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: submissionScenario.existingPrompt }),
|
||||
).toBeVisible();
|
||||
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
|
||||
page,
|
||||
page.getByTestId("assistant-message").last(),
|
||||
);
|
||||
const assistantMessageCount = await page.getByTestId("assistant-message").count();
|
||||
const toolCallCount = await page.getByTestId("tool-call-badge").count();
|
||||
await composer.press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last();
|
||||
await expect(userMessage).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => page.getByTestId("assistant-message").count())
|
||||
.toBeGreaterThan(assistantMessageCount);
|
||||
await expect
|
||||
.poll(async () => page.getByTestId("tool-call-badge").count())
|
||||
.toBeGreaterThan(toolCallCount);
|
||||
await finishTimelineRowStabilityCheck();
|
||||
});
|
||||
|
||||
test("keeps the submitted row stable through draft create handoff", async ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "./fixtures";
|
||||
import {
|
||||
expectSameOlderHistoryLoadingOperation,
|
||||
expectTimelineAtHistoryStart,
|
||||
expectTimelinePromptCentered,
|
||||
expectTimelinePromptNotMounted,
|
||||
expectTimelinePromptPositionPreserved,
|
||||
expectTimelinePromptVisible,
|
||||
@@ -30,12 +31,14 @@ test.describe("Agent timeline pagination", () => {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectTimelinePromptCentered(page, agent.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(2);
|
||||
|
||||
@@ -17,6 +17,7 @@ interface LongTimelineAgentOptions {
|
||||
}
|
||||
|
||||
interface LongTimelineAgent extends MockAgentWorkspace {
|
||||
firstOlderPagePrompt: string;
|
||||
initialTailOldestPrompt: string;
|
||||
oldestPrompt: string;
|
||||
newestPrompt: string;
|
||||
@@ -67,6 +68,7 @@ export async function seedLongMockAgentTimeline(
|
||||
|
||||
return {
|
||||
...agent,
|
||||
firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)),
|
||||
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
|
||||
oldestPrompt: promptForTurn(0),
|
||||
newestPrompt: promptForTurn(options.turns - 1),
|
||||
@@ -115,6 +117,26 @@ export async function expectTimelinePromptVisible(page: Page, prompt: string): P
|
||||
await expect(timeline.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptCentered(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const message = timeline.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(message).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [timelineBox, messageBox] = await Promise.all([
|
||||
timeline.boundingBox(),
|
||||
message.boundingBox(),
|
||||
]);
|
||||
if (!timelineBox || !messageBox) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
const timelineCenter = timelineBox.x + timelineBox.width / 2;
|
||||
const messageCenter = messageBox.x + messageBox.width / 2;
|
||||
return Math.abs(timelineCenter - messageCenter);
|
||||
})
|
||||
.toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
|
||||
@@ -73,6 +73,12 @@ const historyStartSlotStyle: CSSProperties = {
|
||||
paddingBottom: 8,
|
||||
};
|
||||
|
||||
const mountedHistoryRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
function isScrollContainerNearBottom(
|
||||
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
|
||||
@@ -557,25 +563,24 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scheduleStickToBottom,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Following output is a layout invariant: rows, footer, and bottom offset must
|
||||
// reach the browser in the same paint.
|
||||
useLayoutEffect(() => {
|
||||
if (!followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
cancelPendingStickToBottom();
|
||||
scrollMessagesToBottom("auto");
|
||||
}, [
|
||||
scheduleStickToBottom,
|
||||
cancelPendingStickToBottom,
|
||||
renderLiveAuxiliary,
|
||||
scrollMessagesToBottom,
|
||||
segments.historyMounted,
|
||||
segments.historyVirtualized,
|
||||
segments.liveHead,
|
||||
virtualTotalSize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!followOutputRef.current || !shouldUseVirtualizer) {
|
||||
return;
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
@@ -764,7 +769,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const mountedHistoryRows = useMemo(() => {
|
||||
return segments.historyMounted.map((item, index) => (
|
||||
<div key={item.id} data-history-row-id={item.id}>
|
||||
<div key={item.id} data-history-row-id={item.id} style={mountedHistoryRowStyle}>
|
||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||
</div>
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user