fix(webui): follow active turn output after send
This commit is contained in:
@@ -190,6 +190,160 @@ describe("ThreadViewport", () => {
|
||||
expect(screen.getByTestId("thread-message-region")).toHaveClass("justify-start");
|
||||
});
|
||||
|
||||
it("keeps following active agent output after anchoring the sent prompt", async () => {
|
||||
const threaded: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "new question", createdAt: 3 },
|
||||
];
|
||||
const answer: UIMessage = {
|
||||
id: "a2",
|
||||
role: "assistant",
|
||||
content: "streaming answer",
|
||||
createdAt: 4,
|
||||
};
|
||||
const scrollTo = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1200 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, writable: true, value: 700 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
||||
expect(prompt).not.toBeNull();
|
||||
Object.defineProperty(prompt, "offsetTop", {
|
||||
configurable: true,
|
||||
value: 420,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={1}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 404,
|
||||
behavior: "auto",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1800,
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={[...threaded, answer]}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={1}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1300,
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not follow active agent output after the user manually scrolls away", async () => {
|
||||
const threaded: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "new question", createdAt: 3 },
|
||||
];
|
||||
const answer: UIMessage = {
|
||||
id: "a2",
|
||||
role: "assistant",
|
||||
content: "streaming answer",
|
||||
createdAt: 4,
|
||||
};
|
||||
const scrollTo = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1200 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, writable: true, value: 700 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
||||
expect(prompt).not.toBeNull();
|
||||
Object.defineProperty(prompt, "offsetTop", {
|
||||
configurable: true,
|
||||
value: 420,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={1}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
|
||||
scroller.scrollTop = 100;
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1800,
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={[...threaded, answer]}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={1}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const resizeObservers: ResizeObserverInstance[] = [];
|
||||
|
||||
Reference in New Issue
Block a user