fix(webui): preserve semantic history anchors

This commit is contained in:
Xubin Ren
2026-08-27 10:45:40 +08:00
parent 6a3f53a917
commit d7d03b25ef
3 changed files with 27 additions and 8 deletions
@@ -136,6 +136,7 @@ export function ThreadMessages({
return ( return (
<ThreadDisplayUnit <ThreadDisplayUnit
key={unitKeys[index]} key={unitKeys[index]}
unitKey={unitKeys[index]}
unit={unit} unit={unit}
marginTop={marginTop} marginTop={marginTop}
userPromptId={userPromptId} userPromptId={userPromptId}
@@ -225,6 +226,7 @@ function pendingTurnProjection(
} }
interface ThreadDisplayUnitProps { interface ThreadDisplayUnitProps {
unitKey: string;
unit: DisplayUnit; unit: DisplayUnit;
marginTop: string; marginTop: string;
userPromptId?: string; userPromptId?: string;
@@ -243,6 +245,7 @@ interface ThreadDisplayUnitProps {
} }
const ThreadDisplayUnit = memo(function ThreadDisplayUnit({ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
unitKey,
unit, unit,
marginTop, marginTop,
userPromptId, userPromptId,
@@ -273,7 +276,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
<> <>
<div <div
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`} className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
data-thread-display-unit data-thread-display-unit={unitKey}
data-user-prompt-id={userPromptId} data-user-prompt-id={userPromptId}
> >
{unit.type === "activity" ? ( {unit.type === "activity" ? (
+12 -6
View File
@@ -73,7 +73,7 @@ export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120; export const HISTORY_WINDOW_INCREMENT = 120;
interface HistoryScrollAnchor { interface HistoryScrollAnchor {
element: HTMLElement; key: string;
offsetTop: number; offsetTop: number;
} }
@@ -316,8 +316,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
for (const element of units) { for (const element of units) {
const bounds = element.getBoundingClientRect(); const bounds = element.getBoundingClientRect();
if (bounds.bottom <= scrollerTop + 0.5) continue; if (bounds.bottom <= scrollerTop + 0.5) continue;
const key = element.dataset.threadDisplayUnit;
if (!key) continue;
historyScrollAnchorRef.current = { historyScrollAnchorRef.current = {
element, key,
offsetTop: bounds.top - scrollerTop, offsetTop: bounds.top - scrollerTop,
}; };
return true; return true;
@@ -328,15 +330,19 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const reconcileHistoryScrollAnchor = useCallback(() => { const reconcileHistoryScrollAnchor = useCallback(() => {
const scroller = scrollRef.current; const scroller = scrollRef.current;
const content = messageContentRef.current;
const anchor = historyScrollAnchorRef.current; const anchor = historyScrollAnchorRef.current;
if (!scroller || !anchor) return false; if (!scroller || !content || !anchor) return false;
if (!anchor.element.isConnected) { const element = Array.from(
content.querySelectorAll<HTMLElement>("[data-thread-display-unit]"),
).find((candidate) => candidate.dataset.threadDisplayUnit === anchor.key);
if (!element) {
historyScrollAnchorRef.current = null; historyScrollAnchorRef.current = null;
return false; return false;
} }
const nextOffset = const nextOffset =
anchor.element.getBoundingClientRect().top element.getBoundingClientRect().top
- scroller.getBoundingClientRect().top; - scroller.getBoundingClientRect().top;
const delta = nextOffset - anchor.offsetTop; const delta = nextOffset - anchor.offsetTop;
if (Math.abs(delta) < 0.5) return true; if (Math.abs(delta) < 0.5) return true;
@@ -344,7 +350,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const nextTop = Math.min(maxScrollTop, Math.max(0, scroller.scrollTop + delta)); const nextTop = Math.min(maxScrollTop, Math.max(0, scroller.scrollTop + delta));
threadMotionRef.current?.jumpTo(nextTop); threadMotionRef.current?.jumpTo(nextTop);
return true; return true;
}, [captureHistoryScrollAnchor]); }, []);
const scrollToBottomNow = useCallback((smooth = false) => { const scrollToBottomNow = useCallback((smooth = false) => {
historyScrollAnchorRef.current = null; historyScrollAnchorRef.current = null;
+11 -1
View File
@@ -1521,8 +1521,18 @@ describe("ThreadViewport", () => {
dispatchUserScroll(scroller); dispatchUserScroll(scroller);
}); });
const replacement = anchor.cloneNode(true) as HTMLElement;
anchor.replaceWith(replacement);
anchorDocumentTop += 180; anchorDocumentTop += 180;
scrollHeight += 180; scrollHeight += 180;
Object.defineProperty(replacement, "getBoundingClientRect", {
configurable: true,
value: () => DOMRect.fromRect({
y: anchorDocumentTop - scroller.scrollTop,
width: 800,
height: 40,
}),
});
const content = screen.getByTestId("thread-message-region").firstElementChild; const content = screen.getByTestId("thread-message-region").firstElementChild;
const observer = resizeObserver.observers.find((candidate) => const observer = resizeObserver.observers.find((candidate) =>
content ? candidate.elements.includes(content) : false, content ? candidate.elements.includes(content) : false,
@@ -1533,7 +1543,7 @@ describe("ThreadViewport", () => {
}); });
expect(scroller.scrollTop).toBe(260); expect(scroller.scrollTop).toBe(260);
expect(anchor.getBoundingClientRect().top).toBe(120); expect(replacement.getBoundingClientRect().top).toBe(120);
} finally { } finally {
resizeObserver.restore(); resizeObserver.restore();
} }