fix(webui): follow active turn output after send

This commit is contained in:
Xubin Ren
2026-06-22 21:12:34 +08:00
parent 83c29292d3
commit a9d1fdcee8
2 changed files with 193 additions and 3 deletions
+39 -3
View File
@@ -127,7 +127,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const scrollFrameIdsRef = useRef<number[]>([]);
const programmaticPromptScrollTopRef = useRef<number | null>(null);
const handledLatestPromptSignalRef = useRef(0);
const activeTurnPromptRef = useRef<string | null>(null);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
@@ -167,6 +169,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
scrollFrameIdsRef.current = [];
}, []);
const markProgrammaticPromptScroll = useCallback((top: number) => {
programmaticPromptScrollTopRef.current = top;
}, []);
const scrollToBottomNow = useCallback((smooth = false) => {
const el = scrollRef.current;
const marker = bottomRef.current;
@@ -196,6 +202,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const target = findPromptElement(el, promptId);
if (!target) return false;
const top = Math.max(0, promptTop(el, target) - 16);
markProgrammaticPromptScroll(top);
try {
el.scrollTo?.({ top, behavior: "auto" });
el.scrollTop = top;
@@ -207,10 +214,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
}
}
const near = el.scrollHeight - top - el.clientHeight < NEAR_BOTTOM_PX;
userReadingHistoryRef.current = !near;
userReadingHistoryRef.current = false;
setAtBottom(near);
return true;
}, []);
}, [markProgrammaticPromptScroll]);
const scrollToBottom = useCallback(
(smooth = false, frames = 1, options?: { force?: boolean }) => {
@@ -245,6 +252,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
};
}
userReadingHistoryRef.current = true;
activeTurnPromptRef.current = null;
setAtBottom(false);
if (hiddenMessageCount > 0) {
setVisibleMessageCount((count) =>
@@ -277,6 +285,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (index < 0) return;
pendingPromptJumpRef.current = promptId;
userReadingHistoryRef.current = true;
activeTurnPromptRef.current = null;
setAtBottom(false);
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]);
@@ -360,7 +369,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (!latest || latest.role !== "user") return;
handledLatestPromptSignalRef.current = scrollToLatestUserPromptSignal;
cancelScheduledBottomScroll();
scrollToPromptTopNow(latest.id);
activeTurnPromptRef.current = latest.id;
if (!scrollToPromptTopNow(latest.id)) activeTurnPromptRef.current = null;
}, [
cancelScheduledBottomScroll,
messages,
@@ -373,10 +383,26 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
lastConversationKeyRef.current = conversationKey;
pendingConversationScrollRef.current = true;
userReadingHistoryRef.current = false;
activeTurnPromptRef.current = null;
setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
useLayoutEffect(() => {
const promptId = activeTurnPromptRef.current;
if (!promptId || userReadingHistoryRef.current) return;
const promptIndex = messages.findIndex((message) => message.id === promptId);
if (promptIndex < 0) {
activeTurnPromptRef.current = null;
return;
}
const hasAgentOutput = messages
.slice(promptIndex + 1)
.some((message) => message.role !== "user");
if (!hasAgentOutput) return;
scrollToBottom(false, isStreaming ? 3 : 1);
}, [isStreaming, messages, scrollToBottom]);
useLayoutEffect(() => {
const pending = restoreScrollAfterPrependRef.current;
if (!pending) return;
@@ -438,8 +464,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const onScroll = (allowHistoryLoad = true) => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX;
const programmaticPromptTop = programmaticPromptScrollTopRef.current;
const programmatic =
programmaticPromptTop !== null && Math.abs(el.scrollTop - programmaticPromptTop) < 2;
setAtBottom(near);
if (programmatic) {
programmaticPromptScrollTopRef.current = null;
if (near) userReadingHistoryRef.current = false;
return;
}
programmaticPromptScrollTopRef.current = null;
userReadingHistoryRef.current = !near;
if (!near) activeTurnPromptRef.current = null;
if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll();
};
+154
View File
@@ -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[] = [];