Preserve chat scroll-away after delayed history
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { test } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import {
|
import {
|
||||||
awaitAssistantMessage,
|
awaitAssistantMessage,
|
||||||
expectAgentIdle,
|
expectAgentIdle,
|
||||||
@@ -6,8 +6,17 @@ import {
|
|||||||
expectTurnCopyButton,
|
expectTurnCopyButton,
|
||||||
expectScrollFollowsNewContent,
|
expectScrollFollowsNewContent,
|
||||||
} from "./helpers/agent-stream";
|
} 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 { 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.describe("Agent stream UI", () => {
|
||||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
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("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
const agent = await startRunningMockAgent(page, {
|
const agent = await startRunningMockAgent(page, {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||||
|
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||||
|
|
||||||
export interface ScrollMetrics {
|
export interface ScrollMetrics {
|
||||||
offsetY: number;
|
offsetY: number;
|
||||||
@@ -66,3 +67,65 @@ export async function waitForContentGrowth(
|
|||||||
.toBeGreaterThan(previousContentHeight);
|
.toBeGreaterThan(previousContentHeight);
|
||||||
return readScrollMetrics(page);
|
return readScrollMetrics(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForScrollableChat(
|
||||||
|
page: Page,
|
||||||
|
input: { minScrollableDistance: number; timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
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<ScrollMetrics> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<string>;
|
||||||
|
waitForDelayedResponse(): Promise<void>;
|
||||||
|
waitForForwardedResponse(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> | 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayload(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
|
return message.payload && typeof message.payload === "object"
|
||||||
|
? (message.payload as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delayCreatedAgentInitialTailResponse(
|
||||||
|
page: Page,
|
||||||
|
): Promise<CreatedAgentTimelineGate> {
|
||||||
|
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<string>((resolve) => {
|
||||||
|
resolveCreatedAgent = resolve;
|
||||||
|
});
|
||||||
|
const delayedResponse = new Promise<void>((resolve) => {
|
||||||
|
resolveDelayedResponse = resolve;
|
||||||
|
});
|
||||||
|
const forwardedResponse = new Promise<void>((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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -342,9 +342,17 @@ export const selectModel = async (page: Page, model: string) => {
|
|||||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||||
await modelTrigger.click();
|
await modelTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
const modelButton = page
|
||||||
await expect(modelLabel).toBeVisible();
|
.getByRole("button", { name: /Select model/i })
|
||||||
await modelLabel.click();
|
.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
|
// Wait for the model dropdown to open
|
||||||
|
|||||||
@@ -194,7 +194,9 @@ describe("createWebStreamStrategy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
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, "clientHeight", { configurable: true, value: 400 });
|
||||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
|
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
|
||||||
@@ -205,4 +207,201 @@ describe("createWebStreamStrategy", () => {
|
|||||||
|
|
||||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
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<StreamViewportHandle>();
|
||||||
|
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<StreamViewportHandle>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,8 +145,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
|
|
||||||
followOutputRef.current = followOutput;
|
followOutputRef.current = followOutput;
|
||||||
|
|
||||||
|
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
|
||||||
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
|
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
|
||||||
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady;
|
const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady;
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: segments.historyVirtualized.length,
|
count: segments.historyVirtualized.length,
|
||||||
@@ -318,6 +319,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
if (!isActivationReady) {
|
if (!isActivationReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFollowOutput(true);
|
setFollowOutput(true);
|
||||||
forceStickToBottom();
|
forceStickToBottom();
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
@@ -336,7 +340,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]);
|
}, [
|
||||||
|
activationKey,
|
||||||
|
forceStickToBottom,
|
||||||
|
hasRouteBottomAnchorRequest,
|
||||||
|
isActivationReady,
|
||||||
|
scheduleStickToBottom,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!followOutputRef.current) {
|
if (!followOutputRef.current) {
|
||||||
|
|||||||
Reference in New Issue
Block a user