diff --git a/packages/app/e2e/agent-stream-ui.spec.ts b/packages/app/e2e/agent-stream-ui.spec.ts index 65083a2fa..6b52d77c5 100644 --- a/packages/app/e2e/agent-stream-ui.spec.ts +++ b/packages/app/e2e/agent-stream-ui.spec.ts @@ -1,4 +1,4 @@ -import { test } from "./fixtures"; +import { test, expect } from "./fixtures"; import { awaitAssistantMessage, expectAgentIdle, @@ -6,8 +6,17 @@ import { expectTurnCopyButton, expectScrollFollowsNewContent, } from "./helpers/agent-stream"; +import { + expectScrollStaysFixed, + readScrollMetrics, + scrollChatAwayFromBottom, + waitForScrollableChat, +} from "./helpers/agent-bottom-anchor"; +import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate"; +import { selectModel } from "./helpers/app"; import { clickNewChat } from "./helpers/launcher"; -import { startRunningMockAgent } from "./helpers/composer"; +import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; test.describe("Agent stream UI", () => { test("auto-scroll sticks to bottom across token bursts", async ({ page }) => { @@ -25,6 +34,83 @@ test.describe("Agent stream UI", () => { } }); + test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "stream-scroll-away-", + title: "Scroll-away anchor", + model: "five-minute-stream", + initialPrompt: "emit 120 agent stream updates for scroll-away setup.", + }); + try { + await agent.client.waitForFinish(agent.agentId, 30_000); + await openAgentRoute(page, { + workspaceId: agent.workspaceId, + agentId: agent.agentId, + }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "Stream for scroll-away anchor test."); + await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({ + timeout: 30_000, + }); + await awaitAssistantMessage(page); + await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 30_000 }); + const baseline = await scrollChatAwayFromBottom(page, { + deltaY: -900, + minDistanceFromBottom: 300, + }); + await expectScrollStaysFixed(page, baseline, { durationMs: 30_000 }); + + const finalMetrics = await readScrollMetrics(page); + expect(finalMetrics.contentHeight).toBeGreaterThan(baseline.contentHeight); + } finally { + await agent.cleanup(); + } + }); + + test("keeps the viewport fixed when delayed authoritative history arrives after scroll-away", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(180_000); + const timelineGate = await delayCreatedAgentInitialTailResponse(page); + const workspace = await withWorkspace({ + prefix: "stream-scroll-away-delayed-history-", + }); + await workspace.navigateTo(); + await clickNewChat(page); + await page.getByText("Model defaults are still loading").waitFor({ + state: "hidden", + timeout: 30_000, + }); + await expectComposerVisible(page); + await selectModel(page, "Five minute stream"); + + const prompt = "Stream for delayed authoritative history scroll-away test."; + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + await page.getByText(prompt, { exact: true }).first().waitFor({ + state: "visible", + timeout: 30_000, + }); + await timelineGate.waitForCreatedAgent(); + await timelineGate.waitForDelayedResponse(); + await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({ + timeout: 30_000, + }); + await awaitAssistantMessage(page); + await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 45_000 }); + const baseline = await scrollChatAwayFromBottom(page, { + deltaY: -900, + minDistanceFromBottom: 300, + }); + + timelineGate.release(); + await timelineGate.waitForForwardedResponse(); + await expectScrollStaysFixed(page, baseline); + }); + test("working-indicator transitions to copy-button when stream ends", async ({ page }) => { test.setTimeout(60_000); const agent = await startRunningMockAgent(page, { diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts index a7ff60305..561e8ea7e 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -1,6 +1,7 @@ import { expect, type Page } from "@playwright/test"; const NEAR_BOTTOM_THRESHOLD_PX = 72; +const DEFAULT_SCROLL_TOLERANCE_PX = 24; export interface ScrollMetrics { offsetY: number; @@ -66,3 +67,65 @@ export async function waitForContentGrowth( .toBeGreaterThan(previousContentHeight); return readScrollMetrics(page); } + +export async function waitForScrollableChat( + page: Page, + input: { minScrollableDistance: number; timeout?: number }, +): Promise { + await expect + .poll( + async () => { + const metrics = await readScrollMetrics(page); + return metrics.contentHeight - metrics.viewportHeight; + }, + { timeout: input.timeout }, + ) + .toBeGreaterThan(input.minScrollableDistance); +} + +export async function scrollChatAwayFromBottom( + page: Page, + input: { deltaY: number; minDistanceFromBottom: number }, +): Promise { + const scroll = getVisibleChatScroll(page); + const box = await scroll.boundingBox(); + if (!box) { + throw new Error("Agent chat scroll container is not visible"); + } + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, input.deltaY); + + await expect + .poll(async () => { + const metrics = await readScrollMetrics(page); + return metrics.distanceFromBottom; + }) + .toBeGreaterThan(input.minDistanceFromBottom); + + return readScrollMetrics(page); +} + +export async function expectScrollStaysFixed( + page: Page, + baseline: ScrollMetrics, + input?: { durationMs?: number; sampleIntervalMs?: number; tolerancePx?: number }, +): Promise { + const durationMs = input?.durationMs ?? 2_000; + const sampleIntervalMs = input?.sampleIntervalMs ?? 250; + const tolerancePx = input?.tolerancePx ?? DEFAULT_SCROLL_TOLERANCE_PX; + const samples: Array<{ elapsedMs: number; offsetY: number; contentHeight: number }> = []; + const startedAt = Date.now(); + while (Date.now() - startedAt < durationMs) { + await page.waitForTimeout(sampleIntervalMs); + const metrics = await readScrollMetrics(page); + samples.push({ + elapsedMs: Date.now() - startedAt, + offsetY: metrics.offsetY, + contentHeight: metrics.contentHeight, + }); + expect( + metrics.offsetY, + JSON.stringify({ baseline, samples: samples.slice(-12) }), + ).toBeLessThanOrEqual(baseline.offsetY + tolerancePx); + } +} diff --git a/packages/app/e2e/helpers/agent-timeline-gate.ts b/packages/app/e2e/helpers/agent-timeline-gate.ts new file mode 100644 index 000000000..5d8cc0150 --- /dev/null +++ b/packages/app/e2e/helpers/agent-timeline-gate.ts @@ -0,0 +1,120 @@ +import type { Page } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +interface CreatedAgentTimelineGate { + release(): void; + waitForCreatedAgent(): Promise; + waitForDelayedResponse(): Promise; + waitForForwardedResponse(): Promise; +} + +function parseWebSocketJson(message: WebSocketMessage): unknown { + const rawMessage = typeof message === "string" ? message : message.toString("utf8"); + try { + return JSON.parse(rawMessage); + } catch { + return null; + } +} + +function getSessionMessage(message: WebSocketMessage): Record | null { + const envelope = parseWebSocketJson(message); + if (!envelope || typeof envelope !== "object") { + return null; + } + const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; + if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { + return null; + } + if (typeof maybeEnvelope.message !== "object") { + return null; + } + return maybeEnvelope.message as Record; +} + +function getPayload(message: Record): Record | null { + return message.payload && typeof message.payload === "object" + ? (message.payload as Record) + : null; +} + +export async function delayCreatedAgentInitialTailResponse( + page: Page, +): Promise { + let createdAgentId: string | null = null; + let releaseRequested = false; + let delayedResponseSeen = false; + const delayedForwards: Array<() => void> = []; + let resolveCreatedAgent: ((agentId: string) => void) | null = null; + let resolveDelayedResponse: (() => void) | null = null; + let resolveForwardedResponse: (() => void) | null = null; + const createdAgentSeen = new Promise((resolve) => { + resolveCreatedAgent = resolve; + }); + const delayedResponse = new Promise((resolve) => { + resolveDelayedResponse = resolve; + }); + const forwardedResponse = new Promise((resolve) => { + resolveForwardedResponse = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + const forwardToClient = (message: WebSocketMessage) => { + ws.send(message); + resolveForwardedResponse?.(); + }; + + ws.onMessage((message) => { + server.send(message); + }); + + server.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + const payload = sessionMessage ? getPayload(sessionMessage) : null; + if (sessionMessage?.type === "status" && payload?.status === "agent_created") { + const agentId = payload.agentId; + if (typeof agentId === "string") { + createdAgentId = agentId; + resolveCreatedAgent?.(agentId); + } + } + + if (sessionMessage?.type === "fetch_agent_timeline_response") { + const agentId = payload?.agentId; + const direction = payload?.direction; + if ( + !delayedResponseSeen && + typeof agentId === "string" && + agentId === createdAgentId && + direction === "tail" + ) { + delayedResponseSeen = true; + resolveDelayedResponse?.(); + if (releaseRequested) { + forwardToClient(message); + return; + } + delayedForwards.push(() => forwardToClient(message)); + return; + } + } + + ws.send(message); + }); + }); + + return { + release() { + releaseRequested = true; + for (const forward of delayedForwards.splice(0)) { + forward(); + } + }, + waitForCreatedAgent: () => createdAgentSeen, + waitForDelayedResponse: () => delayedResponse, + waitForForwardedResponse: () => forwardedResponse, + }; +} diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index a0d49e967..f89f21bfe 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -342,9 +342,17 @@ export const selectModel = async (page: Page, model: string) => { if (await modelTrigger.isVisible().catch(() => false)) { await modelTrigger.click(); } else { - const modelLabel = page.getByText("MODEL", { exact: true }).first(); - await expect(modelLabel).toBeVisible(); - await modelLabel.click(); + const modelButton = page + .getByRole("button", { name: /Select model/i }) + .filter({ visible: true }) + .first(); + if (await modelButton.isVisible().catch(() => false)) { + await modelButton.click(); + } else { + const modelLabel = page.getByText("MODEL", { exact: true }).first(); + await expect(modelLabel).toBeVisible(); + await modelLabel.click(); + } } // Wait for the model dropdown to open diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 3a689a866..ff8b69788 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -194,7 +194,9 @@ describe("createWebStreamStrategy", () => { }); const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); - expect(scrollContainer).toBeInstanceOf(HTMLElement); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected agent chat scroll container"); + } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 }); Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 }); @@ -205,4 +207,201 @@ describe("createWebStreamStrategy", () => { expect(onNearHistoryStart).toHaveBeenCalledTimes(1); }); + + it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => { + const scrollTo = vi.fn(function ( + this: HTMLElement, + options?: ScrollToOptions | number, + y?: number, + ) { + const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); + Object.defineProperty(this, "scrollTop", { + configurable: true, + value: top, + }); + }); + HTMLElement.prototype.scrollTo = scrollTo; + + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + const viewportRef = React.createRef(); + const routeBottomAnchorRequest = { + agentId: "agent", + reason: "initial-entry" as const, + requestKey: "server:agent:initial-entry", + }; + const renderInput = { + agentId: "agent", + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: false, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn(), + isLoadingOlderHistory: false, + hasOlderHistory: false, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + strategy.render({ + ...renderInput, + segments: { + historyVirtualized: [], + historyMounted: [], + liveHead: [], + }, + isAuthoritativeHistoryReady: false, + }), + ); + }); + + const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected agent chat scroll container"); + } + const scrollElement = scrollContainer; + Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 }); + Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 }); + Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 }); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + scrollTo.mockClear(); + + const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index)); + Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 1400 }); + act(() => { + root?.render( + strategy.render({ + ...renderInput, + segments: { + historyVirtualized: [], + historyMounted, + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + isAuthoritativeHistoryReady: true, + }), + ); + }); + + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + + expect(scrollTo).toHaveBeenCalled(); + expect(scrollElement.scrollTop).toBe(1400); + }); + + it("does not force bottom on delayed route readiness after the user scrolls away", async () => { + const scrollTo = vi.fn(function ( + this: HTMLElement, + options?: ScrollToOptions | number, + y?: number, + ) { + const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); + Object.defineProperty(this, "scrollTop", { + configurable: true, + value: top, + }); + }); + HTMLElement.prototype.scrollTo = scrollTo; + + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + const viewportRef = React.createRef(); + const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index)); + const routeBottomAnchorRequest = { + agentId: "agent", + reason: "initial-entry" as const, + requestKey: "server:agent:initial-entry", + }; + const renderInput = { + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted, + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn(), + isLoadingOlderHistory: false, + hasOlderHistory: false, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + strategy.render({ + ...renderInput, + isAuthoritativeHistoryReady: false, + }), + ); + }); + + const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected agent chat scroll container"); + } + Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 }); + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1400 }); + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + act(() => { + scrollContainer.dispatchEvent(new Event("scroll")); + }); + scrollTo.mockClear(); + + act(() => { + scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 })); + }); + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 }); + act(() => { + scrollContainer.dispatchEvent(new Event("scroll")); + }); + expect(scrollTo).not.toHaveBeenCalled(); + + act(() => { + root?.render( + strategy.render({ + ...renderInput, + isAuthoritativeHistoryReady: true, + }), + ); + }); + + expect(scrollTo).not.toHaveBeenCalled(); + }); }); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 7f5bea596..a5d352659 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -145,8 +145,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool followOutputRef.current = followOutput; + const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null; const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId; - const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady; + const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady; const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, @@ -318,6 +319,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (!isActivationReady) { return; } + if (hasRouteBottomAnchorRequest && !followOutputRef.current) { + return; + } setFollowOutput(true); forceStickToBottom(); const timeout = window.setTimeout(() => { @@ -336,7 +340,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { window.clearTimeout(timeout); }; - }, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]); + }, [ + activationKey, + forceStickToBottom, + hasRouteBottomAnchorRequest, + isActivationReady, + scheduleStickToBottom, + ]); useEffect(() => { if (!followOutputRef.current) {