Preserve chat scroll-away after delayed history
This commit is contained in:
@@ -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<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;
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user