mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
fix(webui): follow active turn output after send
This commit is contained in:
parent
83c29292d3
commit
a9d1fdcee8
@ -127,7 +127,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const pendingConversationScrollRef = useRef(true);
|
const pendingConversationScrollRef = useRef(true);
|
||||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||||
|
const programmaticPromptScrollTopRef = useRef<number | null>(null);
|
||||||
const handledLatestPromptSignalRef = useRef(0);
|
const handledLatestPromptSignalRef = useRef(0);
|
||||||
|
const activeTurnPromptRef = useRef<string | null>(null);
|
||||||
const restoreScrollAfterPrependRef =
|
const restoreScrollAfterPrependRef =
|
||||||
useRef<{ height: number; top: number } | null>(null);
|
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). */
|
/** 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 = [];
|
scrollFrameIdsRef.current = [];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const markProgrammaticPromptScroll = useCallback((top: number) => {
|
||||||
|
programmaticPromptScrollTopRef.current = top;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToBottomNow = useCallback((smooth = false) => {
|
const scrollToBottomNow = useCallback((smooth = false) => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
const marker = bottomRef.current;
|
const marker = bottomRef.current;
|
||||||
@ -196,6 +202,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const target = findPromptElement(el, promptId);
|
const target = findPromptElement(el, promptId);
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
const top = Math.max(0, promptTop(el, target) - 16);
|
const top = Math.max(0, promptTop(el, target) - 16);
|
||||||
|
markProgrammaticPromptScroll(top);
|
||||||
try {
|
try {
|
||||||
el.scrollTo?.({ top, behavior: "auto" });
|
el.scrollTo?.({ top, behavior: "auto" });
|
||||||
el.scrollTop = top;
|
el.scrollTop = top;
|
||||||
@ -207,10 +214,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const near = el.scrollHeight - top - el.clientHeight < NEAR_BOTTOM_PX;
|
const near = el.scrollHeight - top - el.clientHeight < NEAR_BOTTOM_PX;
|
||||||
userReadingHistoryRef.current = !near;
|
userReadingHistoryRef.current = false;
|
||||||
setAtBottom(near);
|
setAtBottom(near);
|
||||||
return true;
|
return true;
|
||||||
}, []);
|
}, [markProgrammaticPromptScroll]);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(
|
const scrollToBottom = useCallback(
|
||||||
(smooth = false, frames = 1, options?: { force?: boolean }) => {
|
(smooth = false, frames = 1, options?: { force?: boolean }) => {
|
||||||
@ -245,6 +252,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
userReadingHistoryRef.current = true;
|
userReadingHistoryRef.current = true;
|
||||||
|
activeTurnPromptRef.current = null;
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
if (hiddenMessageCount > 0) {
|
if (hiddenMessageCount > 0) {
|
||||||
setVisibleMessageCount((count) =>
|
setVisibleMessageCount((count) =>
|
||||||
@ -277,6 +285,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
pendingPromptJumpRef.current = promptId;
|
pendingPromptJumpRef.current = promptId;
|
||||||
userReadingHistoryRef.current = true;
|
userReadingHistoryRef.current = true;
|
||||||
|
activeTurnPromptRef.current = null;
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
|
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
@ -360,7 +369,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
if (!latest || latest.role !== "user") return;
|
if (!latest || latest.role !== "user") return;
|
||||||
handledLatestPromptSignalRef.current = scrollToLatestUserPromptSignal;
|
handledLatestPromptSignalRef.current = scrollToLatestUserPromptSignal;
|
||||||
cancelScheduledBottomScroll();
|
cancelScheduledBottomScroll();
|
||||||
scrollToPromptTopNow(latest.id);
|
activeTurnPromptRef.current = latest.id;
|
||||||
|
if (!scrollToPromptTopNow(latest.id)) activeTurnPromptRef.current = null;
|
||||||
}, [
|
}, [
|
||||||
cancelScheduledBottomScroll,
|
cancelScheduledBottomScroll,
|
||||||
messages,
|
messages,
|
||||||
@ -373,10 +383,26 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
lastConversationKeyRef.current = conversationKey;
|
lastConversationKeyRef.current = conversationKey;
|
||||||
pendingConversationScrollRef.current = true;
|
pendingConversationScrollRef.current = true;
|
||||||
userReadingHistoryRef.current = false;
|
userReadingHistoryRef.current = false;
|
||||||
|
activeTurnPromptRef.current = null;
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||||
}, [conversationKey]);
|
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
const pending = restoreScrollAfterPrependRef.current;
|
const pending = restoreScrollAfterPrependRef.current;
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
@ -438,8 +464,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const onScroll = (allowHistoryLoad = true) => {
|
const onScroll = (allowHistoryLoad = true) => {
|
||||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
const near = distance < NEAR_BOTTOM_PX;
|
const near = distance < NEAR_BOTTOM_PX;
|
||||||
|
const programmaticPromptTop = programmaticPromptScrollTopRef.current;
|
||||||
|
const programmatic =
|
||||||
|
programmaticPromptTop !== null && Math.abs(el.scrollTop - programmaticPromptTop) < 2;
|
||||||
setAtBottom(near);
|
setAtBottom(near);
|
||||||
|
if (programmatic) {
|
||||||
|
programmaticPromptScrollTopRef.current = null;
|
||||||
|
if (near) userReadingHistoryRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
programmaticPromptScrollTopRef.current = null;
|
||||||
userReadingHistoryRef.current = !near;
|
userReadingHistoryRef.current = !near;
|
||||||
|
if (!near) activeTurnPromptRef.current = null;
|
||||||
if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll();
|
if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -190,6 +190,160 @@ describe("ThreadViewport", () => {
|
|||||||
expect(screen.getByTestId("thread-message-region")).toHaveClass("justify-start");
|
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", () => {
|
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const resizeObservers: ResizeObserverInstance[] = [];
|
const resizeObservers: ResizeObserverInstance[] = [];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user