mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
* fix(app): fill short collapsed chat histories Collapsed whole turns can leave the initial viewport short even when older history exists. Continue paging through measured pages before revealing a fresh chat, while preserving visible history and the reading anchor. * fix(app): settle padded and stalled chat history Measure the native history edge using the same content area as viewport filling, and bound child history requests so retry can start after a stalled fetch. * fix(app): adapt viewport filling to the current timeline * fix(app): resume history filling after switching tabs
1664 lines
56 KiB
TypeScript
1664 lines
56 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
|
import React from "react";
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { RetainedPanelActivity } from "@/components/retained-panel";
|
|
import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
|
|
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
|
import { createWebStreamStrategy } from "./strategy-web";
|
|
|
|
vi.hoisted(() => {
|
|
Object.defineProperty(window, "matchMedia", {
|
|
configurable: true,
|
|
value: () => ({
|
|
addEventListener: () => {},
|
|
addListener: () => {},
|
|
dispatchEvent: () => false,
|
|
matches: false,
|
|
media: "",
|
|
onchange: null,
|
|
removeEventListener: () => {},
|
|
removeListener: () => {},
|
|
}),
|
|
});
|
|
});
|
|
|
|
vi.mock("react-native-unistyles", () => ({
|
|
StyleSheet: { create: () => ({}) },
|
|
withUnistyles: (Component: React.ComponentType) => Component,
|
|
}));
|
|
|
|
function userMessage(index: number): Extract<StreamItem, { kind: "user_message" }> {
|
|
return {
|
|
kind: "user_message",
|
|
id: `message-${index}`,
|
|
text: `Message ${index}`,
|
|
timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`),
|
|
};
|
|
}
|
|
|
|
const VIRTUAL_ROW_STYLE = { height: 24 };
|
|
|
|
function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
|
|
return {
|
|
renderHistoryVirtualizedRow: (item) => {
|
|
onRowRender();
|
|
return <div style={VIRTUAL_ROW_STYLE}>{item.id}</div>;
|
|
},
|
|
renderHistoryMountedRow: (item) => <div>{item.id}</div>,
|
|
renderLiveHeadRow: (item) => <div>{item.id}</div>,
|
|
renderLiveAuxiliary: () => null,
|
|
};
|
|
}
|
|
|
|
function MeasuredTestRow({ item }: { item: StreamItem }) {
|
|
const [loaded, setLoaded] = React.useState(false);
|
|
const load = React.useCallback(() => setLoaded(true), []);
|
|
const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24;
|
|
const measure = React.useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
if (!node?.parentElement) return;
|
|
Object.defineProperty(node.parentElement, "offsetHeight", {
|
|
configurable: true,
|
|
value: height,
|
|
});
|
|
},
|
|
[height],
|
|
);
|
|
return (
|
|
<div ref={measure}>
|
|
<button type="button" onClick={load}>
|
|
{loaded ? "Loaded" : "Load"}
|
|
</button>
|
|
{item.id}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("createWebStreamStrategy", () => {
|
|
let root: Root | null = null;
|
|
let container: HTMLDivElement | null = null;
|
|
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
|
|
let originalOffsetHeight: PropertyDescriptor | undefined;
|
|
|
|
beforeEach(() => {
|
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(globalThis, "ResizeObserver", {
|
|
value: class ResizeObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
},
|
|
configurable: true,
|
|
});
|
|
originalScrollTo = HTMLElement.prototype.scrollTo;
|
|
HTMLElement.prototype.scrollTo = vi.fn();
|
|
originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
configurable: true,
|
|
get() {
|
|
return 24;
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root) {
|
|
act(() => {
|
|
root?.unmount();
|
|
});
|
|
}
|
|
root = null;
|
|
container?.remove();
|
|
container = null;
|
|
if (originalScrollTo) {
|
|
HTMLElement.prototype.scrollTo = originalScrollTo;
|
|
} else {
|
|
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
|
|
}
|
|
if (originalOffsetHeight) {
|
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight);
|
|
} else {
|
|
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it.each(["liveHead", "historyMounted"] as const)(
|
|
"keeps one rendered tool row after late prior-turn updates in %s",
|
|
(lane) => {
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
const input: StreamRenderInput = {
|
|
agentId: "agent",
|
|
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: lane === "historyMounted",
|
|
hasLiveHead: lane === "liveHead",
|
|
},
|
|
renderers: createRenderers(() => {}),
|
|
listEmptyComponent: null,
|
|
viewportRef: React.createRef<StreamViewportHandle>(),
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: () => {},
|
|
onNearHistoryStart: async () => true,
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
let items: StreamItem[] = [];
|
|
const updates = [
|
|
{ callId: "background", turnId: "old", status: "running" },
|
|
{ callId: "exec", turnId: "current", status: "running" },
|
|
{ callId: "background", turnId: "old", status: "completed" },
|
|
{ callId: "exec", turnId: "current", status: "completed" },
|
|
] as const;
|
|
for (const [index, update] of updates.entries()) {
|
|
items = reduceStreamUpdate(
|
|
items,
|
|
{
|
|
type: "timeline",
|
|
provider: "codex",
|
|
turnId: update.turnId,
|
|
item: {
|
|
type: "tool_call",
|
|
callId: update.callId,
|
|
name: "exec_command",
|
|
status: update.status,
|
|
error: null,
|
|
detail: { type: "shell", command: update.callId },
|
|
},
|
|
},
|
|
new Date(index),
|
|
);
|
|
act(() =>
|
|
root?.render(
|
|
strategy.render({
|
|
...input,
|
|
segments: { ...input.segments, [lane]: items },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
const rows = [...container.querySelectorAll("[data-history-row-id]")];
|
|
expect(rows.map((row) => row.getAttribute("data-history-row-id"))).toEqual([
|
|
"agent_tool_turn:old/background",
|
|
"agent_tool_turn:current/exec",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("mounts virtualized history without recursive row measurement updates", () => {
|
|
const rowRenderCount = vi.fn();
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
const historyVirtualized = Array.from({ length: 16 }, (_, index) => userMessage(index));
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
<>
|
|
{strategy.render({
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized,
|
|
historyMounted: [],
|
|
liveHead: [],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: true,
|
|
hasMountedHistory: false,
|
|
hasLiveHead: false,
|
|
},
|
|
renderers: createRenderers(rowRenderCount),
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
})}
|
|
</>,
|
|
);
|
|
});
|
|
|
|
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
|
|
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
|
|
});
|
|
|
|
it("retains a row's DOM and local state when recent history becomes virtualized", () => {
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
const item = userMessage(0);
|
|
const renderRow = (rowItem: StreamItem) => <MeasuredTestRow item={rowItem} />;
|
|
const input: StreamRenderInput = {
|
|
agentId: "agent",
|
|
segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
|
|
boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
|
|
renderers: {
|
|
renderHistoryVirtualizedRow: renderRow,
|
|
renderHistoryMountedRow: renderRow,
|
|
renderLiveHeadRow: renderRow,
|
|
renderLiveAuxiliary: () => null,
|
|
},
|
|
listEmptyComponent: null,
|
|
viewportRef: React.createRef<StreamViewportHandle>(),
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: () => {},
|
|
onNearHistoryStart: async () => true,
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
act(() => root?.render(strategy.render(input)));
|
|
const row = container.querySelector('[data-history-row-id="message-0"]')!;
|
|
act(() => row.querySelector("button")!.click());
|
|
expect(row.querySelector("button")!.textContent).toBe("Loaded");
|
|
const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!;
|
|
Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 });
|
|
act(() =>
|
|
root?.render(
|
|
strategy.render({
|
|
...input,
|
|
segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] },
|
|
boundary: { ...input.boundary, hasVirtualizedHistory: true },
|
|
}),
|
|
),
|
|
);
|
|
expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row);
|
|
expect(row.isConnected).toBe(true);
|
|
expect(row.querySelector("button")!.textContent).toBe("Loaded");
|
|
act(() =>
|
|
root?.render(
|
|
strategy.render({
|
|
...input,
|
|
segments: {
|
|
historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)],
|
|
historyMounted: [userMessage(2)],
|
|
liveHead: [],
|
|
},
|
|
boundary: { ...input.boundary, hasVirtualizedHistory: true },
|
|
}),
|
|
),
|
|
);
|
|
expect(
|
|
(container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform,
|
|
).toBe("translateY(120px)");
|
|
expect(row.querySelector("button")!.textContent).toBe("Loaded");
|
|
});
|
|
|
|
it("keeps the timeline width stable with an overlay scrollbar", () => {
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [userMessage(1)],
|
|
liveHead: [],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: false,
|
|
},
|
|
renderers: createRenderers(vi.fn()),
|
|
listEmptyComponent: null,
|
|
viewportRef: React.createRef<StreamViewportHandle>(),
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
}),
|
|
);
|
|
});
|
|
|
|
const scrollContainer = container.querySelector<HTMLElement>(
|
|
'[data-testid="agent-chat-scroll"]',
|
|
);
|
|
expect(scrollContainer?.style.scrollbarWidth).toBe("none");
|
|
expect(scrollContainer?.dataset.overlayScrollbar).toBe("true");
|
|
});
|
|
|
|
it("rerenders a stable live-head row when its revision changes", () => {
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
const liveHead = [userMessage(1)];
|
|
let label = "collapsed";
|
|
const renderLiveHeadRow = vi.fn(() => <div>{label}</div>);
|
|
const renderInput: StreamRenderInput = {
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [],
|
|
liveHead,
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: false,
|
|
hasLiveHead: true,
|
|
},
|
|
renderers: {
|
|
...createRenderers(vi.fn()),
|
|
renderLiveHeadRow,
|
|
},
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 0 }));
|
|
});
|
|
expect(container.textContent).toContain("collapsed");
|
|
|
|
label = "expanded";
|
|
act(() => {
|
|
root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 1 }));
|
|
});
|
|
|
|
expect(container.textContent).toContain("expanded");
|
|
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("rerenders only the history row whose content revision changed", () => {
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
const host = userMessage(1);
|
|
const other = userMessage(2);
|
|
const renderCountById = new Map<string, number>();
|
|
const HistoryRow = React.memo(function HistoryRow({ item }: { item: StreamItem }) {
|
|
renderCountById.set(item.id, (renderCountById.get(item.id) ?? 0) + 1);
|
|
return <div>{item.id}</div>;
|
|
});
|
|
const renderInput: StreamRenderInput = {
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [host, other],
|
|
liveHead: [],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: false,
|
|
},
|
|
renderers: {
|
|
...createRenderers(vi.fn()),
|
|
renderHistoryMountedRow: (item) => <HistoryRow item={item} />,
|
|
},
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
const unrevised = {
|
|
contentById: new Set<string>(),
|
|
displayStateById: new Set<string>(),
|
|
globalDisplayState: false,
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(strategy.render({ ...renderInput, historyRowRevision: unrevised }));
|
|
});
|
|
expect(renderCountById.get(host.id)).toBe(1);
|
|
expect(renderCountById.get(other.id)).toBe(1);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
historyRowRevision: { ...unrevised, contentById: new Set([host.id]) },
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(renderCountById.get(host.id)).toBe(2);
|
|
expect(renderCountById.get(other.id)).toBe(1);
|
|
});
|
|
|
|
it("keeps a row mounted when it moves from the live head into mounted history", () => {
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
const item = userMessage(1);
|
|
const mounted = vi.fn();
|
|
const unmounted = vi.fn();
|
|
function StatefulRow() {
|
|
React.useEffect(() => {
|
|
mounted();
|
|
return unmounted;
|
|
}, []);
|
|
return <div>{item.id}</div>;
|
|
}
|
|
const renderers: StreamSegmentRenderers = {
|
|
...createRenderers(vi.fn()),
|
|
renderHistoryMountedRow: () => <StatefulRow />,
|
|
renderLiveHeadRow: () => <StatefulRow />,
|
|
};
|
|
const renderInput: Omit<StreamRenderInput, "segments" | "boundary"> = {
|
|
agentId: "agent",
|
|
renderers,
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
segments: { historyVirtualized: [], historyMounted: [], liveHead: [item] },
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: false,
|
|
hasLiveHead: true,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
expect(mounted).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: false,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(mounted).toHaveBeenCalledTimes(1);
|
|
expect(unmounted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports the live-head row as the reading position", () => {
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
const onReadingPositionChange = vi.fn();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [userMessage(1)],
|
|
liveHead: [userMessage(2)],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: true,
|
|
},
|
|
renderers: createRenderers(vi.fn()),
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onReadingPositionChange,
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-2");
|
|
});
|
|
|
|
it("keeps bottom anchoring through subpixel browser rounding", () => {
|
|
const scrollTo = vi.fn();
|
|
HTMLElement.prototype.scrollTo = scrollTo;
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [userMessage(1)],
|
|
liveHead: [],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: false,
|
|
},
|
|
renderers: createRenderers(vi.fn()),
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
}),
|
|
);
|
|
});
|
|
|
|
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: 766 });
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 });
|
|
scrollContainer.scrollTop = 4959.1708984375;
|
|
scrollTo.mockClear();
|
|
|
|
act(() => {
|
|
viewportRef.current?.scrollToBottom("message-sent");
|
|
});
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
|
|
|
|
scrollTo.mockClear();
|
|
scrollContainer.scrollTop = 4960.5;
|
|
|
|
act(() => {
|
|
viewportRef.current?.scrollToBottom("message-sent");
|
|
});
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
|
|
|
|
scrollTo.mockClear();
|
|
scrollContainer.scrollTop = 4967;
|
|
|
|
act(() => {
|
|
viewportRef.current?.scrollToBottom("message-sent");
|
|
});
|
|
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("waits for bottom anchoring before evaluating a delayed initial tail", async () => {
|
|
HTMLElement.prototype.scrollTo = vi.fn(function (
|
|
this: HTMLElement,
|
|
options?: ScrollToOptions | number,
|
|
y?: number,
|
|
) {
|
|
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
|
this.scrollTop = top;
|
|
});
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
const onNearHistoryStart = vi.fn().mockReturnValue(true);
|
|
const renderInput = {
|
|
agentId: "agent",
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: false,
|
|
hasLiveHead: false,
|
|
},
|
|
renderers: createRenderers(vi.fn()),
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart,
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
|
|
}),
|
|
);
|
|
});
|
|
await act(async () => {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
});
|
|
|
|
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: 1200 });
|
|
scrollContainer.scrollTop = 0;
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [userMessage(1), userMessage(2)],
|
|
liveHead: [],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: false,
|
|
},
|
|
hasOlderHistory: true,
|
|
olderHistoryProgressKey: "epoch-1:20",
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
});
|
|
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
this.scrollTop = 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().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
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 });
|
|
scrollElement.scrollTop = 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);
|
|
this.scrollTop = 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 onNearHistoryStart = vi.fn().mockReturnValue(false);
|
|
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,
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: true,
|
|
olderHistoryProgressKey: "epoch-1:20",
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
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 });
|
|
scrollContainer.scrollTop = 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 }));
|
|
});
|
|
scrollContainer.scrollTop = 0;
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
isAuthoritativeHistoryReady: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
await act(async () => Promise.resolve());
|
|
expect(onNearHistoryStart).toHaveBeenCalledOnce();
|
|
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
|
});
|
|
await act(async () => Promise.resolve());
|
|
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
|
|
const scrollTo = vi.fn(function (
|
|
this: HTMLElement,
|
|
options?: ScrollToOptions | number,
|
|
y?: number,
|
|
) {
|
|
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
|
this.scrollTop = 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().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
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: 500 });
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
|
|
scrollContainer.scrollTop = 0;
|
|
await act(async () => {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
});
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
scrollContainer.scrollTop = 991;
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
|
|
});
|
|
scrollContainer.scrollTop = 91;
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
|
|
scrollContainer.scrollTop = 0;
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2531 });
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
isAuthoritativeHistoryReady: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reattaches follow-output when a small scroll range returns to bottom", async () => {
|
|
const scrollTo = vi.fn(function (
|
|
this: HTMLElement,
|
|
options?: ScrollToOptions | number,
|
|
y?: number,
|
|
) {
|
|
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
|
this.scrollTop = top;
|
|
});
|
|
HTMLElement.prototype.scrollTo = scrollTo;
|
|
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
const renderInput = {
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [userMessage(1), userMessage(2)],
|
|
liveHead: [],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: false,
|
|
},
|
|
renderers: createRenderers(vi.fn()),
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
isAuthoritativeHistoryReady: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
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: 500 });
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
|
|
scrollContainer.scrollTop = 50;
|
|
await act(async () => {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
});
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
|
|
});
|
|
scrollContainer.scrollTop = 20;
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
|
|
scrollContainer.scrollTop = 50;
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
segments: {
|
|
...renderInput.segments,
|
|
liveHead: [userMessage(3)],
|
|
},
|
|
isAuthoritativeHistoryReady: true,
|
|
}),
|
|
);
|
|
});
|
|
await act(async () => {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
});
|
|
|
|
expect(scrollTo).toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => {
|
|
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
|
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
|
this.scrollTop = top;
|
|
});
|
|
HTMLElement.prototype.scrollTo = scrollTo;
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
const renderInput: StreamRenderInput = {
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [userMessage(1), userMessage(2)],
|
|
liveHead: [],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: false,
|
|
},
|
|
renderers: createRenderers(vi.fn()),
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => root?.render(strategy.render(renderInput)));
|
|
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: 500 });
|
|
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
|
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
|
scrollContainer.scrollTop = 1000;
|
|
scrollContainer.getBoundingClientRect = () =>
|
|
({
|
|
bottom: 500,
|
|
height: 500,
|
|
left: 0,
|
|
right: 500,
|
|
top: 0,
|
|
width: 500,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => {},
|
|
}) as DOMRect;
|
|
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
|
|
|
const jitterPointerDown = new MouseEvent("pointerdown", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX: 100,
|
|
clientY: 400,
|
|
});
|
|
const jitterPointerMove = new MouseEvent("pointermove", {
|
|
bubbles: true,
|
|
buttons: 1,
|
|
clientX: 100,
|
|
clientY: 397,
|
|
});
|
|
for (const event of [jitterPointerDown, jitterPointerMove]) {
|
|
Object.defineProperties(event, {
|
|
isPrimary: { value: true },
|
|
pointerId: { value: 1 },
|
|
pointerType: { value: "mouse" },
|
|
});
|
|
}
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(jitterPointerDown);
|
|
window.dispatchEvent(jitterPointerMove);
|
|
});
|
|
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 });
|
|
scrollContainer.scrollTop = 800;
|
|
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
|
scrollTo.mockClear();
|
|
|
|
const nestedScroller = document.createElement("div");
|
|
nestedScroller.style.overflowY = "auto";
|
|
Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 });
|
|
Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 });
|
|
nestedScroller.scrollTop = 100;
|
|
scrollContainer.append(nestedScroller);
|
|
act(() => {
|
|
nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
|
|
scrollContainer.scrollTop = 700;
|
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
scrollContainer.dispatchEvent(
|
|
new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }),
|
|
);
|
|
});
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
|
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: "auto" });
|
|
});
|
|
|
|
it("stops following output after scrollbar and middle-button upward scrolling", () => {
|
|
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
|
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
|
this.scrollTop = top;
|
|
});
|
|
HTMLElement.prototype.scrollTo = scrollTo;
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
|
const renderInput: StreamRenderInput = {
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [userMessage(1), userMessage(2)],
|
|
liveHead: [],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: false,
|
|
},
|
|
renderers: createRenderers(vi.fn()),
|
|
listEmptyComponent: null,
|
|
viewportRef: React.createRef<StreamViewportHandle>(),
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => root?.render(strategy.render(renderInput)));
|
|
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: 500 });
|
|
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
|
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 });
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
|
scrollContainer.scrollTop = 1000;
|
|
scrollContainer.getBoundingClientRect = () =>
|
|
({
|
|
bottom: 500,
|
|
height: 500,
|
|
left: 0,
|
|
right: 520,
|
|
top: 0,
|
|
width: 520,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => {},
|
|
}) as DOMRect;
|
|
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
|
|
|
const scrollbarPointerDown = new MouseEvent("pointerdown", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX: 515,
|
|
clientY: 400,
|
|
});
|
|
Object.defineProperties(scrollbarPointerDown, {
|
|
isPrimary: { value: true },
|
|
pointerId: { value: 1 },
|
|
pointerType: { value: "mouse" },
|
|
});
|
|
act(() => scrollContainer.dispatchEvent(scrollbarPointerDown));
|
|
scrollContainer.scrollTop = 700;
|
|
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
|
scrollTo.mockClear();
|
|
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
|
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
|
}),
|
|
);
|
|
});
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
|
|
scrollContainer.scrollTop = 1300;
|
|
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
|
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
|
scrollContainer.getBoundingClientRect = () =>
|
|
({
|
|
bottom: 500,
|
|
height: 500,
|
|
left: 0,
|
|
right: 500,
|
|
top: 0,
|
|
width: 500,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => {},
|
|
}) as DOMRect;
|
|
const overlayScrollbarPointerDown = new MouseEvent("pointerdown", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX: 495,
|
|
clientY: 400,
|
|
});
|
|
Object.defineProperties(overlayScrollbarPointerDown, {
|
|
isPrimary: { value: true },
|
|
pointerId: { value: 2 },
|
|
pointerType: { value: "mouse" },
|
|
});
|
|
act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown));
|
|
scrollContainer.scrollTop = 900;
|
|
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
|
scrollTo.mockClear();
|
|
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1900 });
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
segments: {
|
|
...renderInput.segments,
|
|
liveHead: [userMessage(3), userMessage(4)],
|
|
},
|
|
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
|
}),
|
|
);
|
|
});
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
|
|
scrollContainer.scrollTop = 1400;
|
|
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
|
const autoscrollPointerDown = new MouseEvent("pointerdown", {
|
|
bubbles: true,
|
|
button: 1,
|
|
buttons: 4,
|
|
clientX: 100,
|
|
clientY: 400,
|
|
});
|
|
const autoscrollPointerUp = new MouseEvent("pointerup", {
|
|
bubbles: true,
|
|
button: 1,
|
|
buttons: 0,
|
|
clientX: 100,
|
|
clientY: 400,
|
|
});
|
|
const autoscrollPointerMove = new MouseEvent("pointermove", {
|
|
bubbles: true,
|
|
buttons: 0,
|
|
clientX: 100,
|
|
clientY: 300,
|
|
});
|
|
for (const event of [autoscrollPointerDown, autoscrollPointerUp, autoscrollPointerMove]) {
|
|
Object.defineProperties(event, {
|
|
isPrimary: { value: true },
|
|
pointerId: { value: 3 },
|
|
pointerType: { value: "mouse" },
|
|
});
|
|
}
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(autoscrollPointerDown);
|
|
window.dispatchEvent(autoscrollPointerUp);
|
|
window.dispatchEvent(autoscrollPointerMove);
|
|
});
|
|
scrollContainer.scrollTop = 1000;
|
|
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
|
scrollTo.mockClear();
|
|
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2000 });
|
|
act(() => {
|
|
root?.render(
|
|
strategy.render({
|
|
...renderInput,
|
|
segments: {
|
|
...renderInput.segments,
|
|
liveHead: [userMessage(3), userMessage(4), userMessage(5)],
|
|
},
|
|
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
|
}),
|
|
);
|
|
});
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the retained viewport mounted while inactive and reconciles it once on return", async () => {
|
|
interface ResizeObservation {
|
|
callback: ResizeObserverCallback;
|
|
disconnect: ReturnType<typeof vi.fn>;
|
|
observer: ResizeObserver;
|
|
}
|
|
const observed = new Map<Element, ResizeObservation>();
|
|
Object.defineProperty(globalThis, "ResizeObserver", {
|
|
configurable: true,
|
|
value: class TestResizeObserver {
|
|
readonly disconnect = vi.fn();
|
|
|
|
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
|
|
observe(target: Element) {
|
|
observed.set(target, {
|
|
callback: this.callback,
|
|
disconnect: this.disconnect,
|
|
observer: this as unknown as ResizeObserver,
|
|
});
|
|
}
|
|
},
|
|
});
|
|
const notifyResize = (...targets: Element[]) => {
|
|
const observation = targets[0] ? observed.get(targets[0]) : undefined;
|
|
if (!observation) {
|
|
throw new Error("Expected observed resize target");
|
|
}
|
|
observation.callback(
|
|
targets.map(
|
|
(target) =>
|
|
({
|
|
target,
|
|
contentRect: target.getBoundingClientRect(),
|
|
}) as ResizeObserverEntry,
|
|
),
|
|
observation.observer,
|
|
);
|
|
};
|
|
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
|
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
|
this.scrollTop = top;
|
|
});
|
|
HTMLElement.prototype.scrollTo = scrollTo;
|
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
|
const onReadingPositionChange = vi.fn();
|
|
const renderInput: StreamRenderInput = {
|
|
agentId: "agent",
|
|
segments: {
|
|
historyVirtualized: [],
|
|
historyMounted: [userMessage(1), userMessage(2)],
|
|
liveHead: [],
|
|
},
|
|
boundary: {
|
|
hasVirtualizedHistory: false,
|
|
hasMountedHistory: true,
|
|
hasLiveHead: false,
|
|
},
|
|
renderers: createRenderers(vi.fn()),
|
|
listEmptyComponent: null,
|
|
viewportRef,
|
|
routeBottomAnchorRequest: null,
|
|
isAuthoritativeHistoryReady: true,
|
|
onNearBottomChange: vi.fn(),
|
|
onReadingPositionChange,
|
|
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
|
isLoadingOlderHistory: false,
|
|
hasOlderHistory: false,
|
|
olderHistoryProgressKey: null,
|
|
scrollEnabled: true,
|
|
listStyle: null,
|
|
baseListContentContainerStyle: null,
|
|
forwardListContentContainerStyle: null,
|
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
|
};
|
|
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
|
|
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
|
|
);
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => root?.render(renderWithActivity(true)));
|
|
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
|
if (!(scrollContainer instanceof HTMLElement)) {
|
|
throw new Error("Expected agent chat scroll container");
|
|
}
|
|
const contentNode = scrollContainer.firstElementChild;
|
|
if (!(contentNode instanceof HTMLElement)) {
|
|
throw new Error("Expected agent chat content node");
|
|
}
|
|
const firstRow = contentNode.querySelector('[data-history-row-id="message-1"]');
|
|
const secondRow = contentNode.querySelector('[data-history-row-id="message-2"]');
|
|
if (!(firstRow instanceof HTMLElement) || !(secondRow instanceof HTMLElement)) {
|
|
throw new Error("Expected retained history rows");
|
|
}
|
|
let rowsShifted = false;
|
|
scrollContainer.getBoundingClientRect = () => ({ top: 0, height: 500, bottom: 500 }) as DOMRect;
|
|
firstRow.getBoundingClientRect = () =>
|
|
({ top: rowsShifted ? -116 : 0, height: 120, bottom: rowsShifted ? 4 : 120 }) as DOMRect;
|
|
secondRow.getBoundingClientRect = () =>
|
|
({
|
|
top: rowsShifted ? 4 : 120,
|
|
height: rowsShifted ? 116 : 40,
|
|
bottom: rowsShifted ? 120 : 160,
|
|
}) as DOMRect;
|
|
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
|
scrollContainer.scrollTop = 1000;
|
|
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
|
const viewportObservation = observed.get(scrollContainer);
|
|
expect(viewportObservation).toBeDefined();
|
|
act(() => notifyResize(scrollContainer, contentNode));
|
|
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-1");
|
|
|
|
act(() => root?.render(renderWithActivity(false)));
|
|
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
|
|
expect(viewportObservation?.disconnect).toHaveBeenCalledOnce();
|
|
scrollTo.mockClear();
|
|
rowsShifted = true;
|
|
act(() => root?.render(renderWithActivity(true)));
|
|
act(() => notifyResize(scrollContainer, contentNode));
|
|
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-2");
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
|
|
act(() => root?.render(renderWithActivity(false)));
|
|
scrollTo.mockClear();
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
|
act(() => root?.render(renderWithActivity(true)));
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
act(() => notifyResize(scrollContainer, contentNode));
|
|
await act(async () => {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
});
|
|
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
|
|
|
|
act(() => root?.render(renderWithActivity(false)));
|
|
scrollTo.mockClear();
|
|
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
|
scrollContainer.scrollTop = 0;
|
|
act(() => {
|
|
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
|
root?.render(
|
|
renderWithActivity(false, {
|
|
...renderInput,
|
|
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
|
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
|
}),
|
|
);
|
|
});
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root?.render(
|
|
renderWithActivity(true, {
|
|
...renderInput,
|
|
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
|
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
|
}),
|
|
);
|
|
});
|
|
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
|
|
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
|
|
});
|
|
});
|