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:
Mohamed Boudra
2026-07-29 12:28:25 +02:00
parent 44236ea474
commit ceb06116f2
5 changed files with 400 additions and 70 deletions

View 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();
}
});

View File

@@ -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: ![Existing timeline image](existing-timeline.png)",
});
} 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 ({

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>
));