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 (
<ThreadDisplayUnit
key={unitKeys[index]}
unitKey={unitKeys[index]}
unit={unit}
marginTop={marginTop}
userPromptId={userPromptId}
@@ -225,6 +226,7 @@ function pendingTurnProjection(
}
interface ThreadDisplayUnitProps {
unitKey: string;
unit: DisplayUnit;
marginTop: string;
userPromptId?: string;
@@ -243,6 +245,7 @@ interface ThreadDisplayUnitProps {
}
const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
unitKey,
unit,
marginTop,
userPromptId,
@@ -273,7 +276,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
<>
<div
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
data-thread-display-unit
data-thread-display-unit={unitKey}
data-user-prompt-id={userPromptId}
>
{unit.type === "activity" ? (
+12 -6
View File
@@ -73,7 +73,7 @@ export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
interface HistoryScrollAnchor {
element: HTMLElement;
key: string;
offsetTop: number;
}
@@ -316,8 +316,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
for (const element of units) {
const bounds = element.getBoundingClientRect();
if (bounds.bottom <= scrollerTop + 0.5) continue;
const key = element.dataset.threadDisplayUnit;
if (!key) continue;
historyScrollAnchorRef.current = {
element,
key,
offsetTop: bounds.top - scrollerTop,
};
return true;
@@ -328,15 +330,19 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const reconcileHistoryScrollAnchor = useCallback(() => {
const scroller = scrollRef.current;
const content = messageContentRef.current;
const anchor = historyScrollAnchorRef.current;
if (!scroller || !anchor) return false;
if (!anchor.element.isConnected) {
if (!scroller || !content || !anchor) return false;
const element = Array.from(
content.querySelectorAll<HTMLElement>("[data-thread-display-unit]"),
).find((candidate) => candidate.dataset.threadDisplayUnit === anchor.key);
if (!element) {
historyScrollAnchorRef.current = null;
return false;
}
const nextOffset =
anchor.element.getBoundingClientRect().top
element.getBoundingClientRect().top
- scroller.getBoundingClientRect().top;
const delta = nextOffset - anchor.offsetTop;
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));
threadMotionRef.current?.jumpTo(nextTop);
return true;
}, [captureHistoryScrollAnchor]);
}, []);
const scrollToBottomNow = useCallback((smooth = false) => {
historyScrollAnchorRef.current = null;
+11 -1
View File
@@ -1521,8 +1521,18 @@ describe("ThreadViewport", () => {
dispatchUserScroll(scroller);
});
const replacement = anchor.cloneNode(true) as HTMLElement;
anchor.replaceWith(replacement);
anchorDocumentTop += 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 observer = resizeObserver.observers.find((candidate) =>
content ? candidate.elements.includes(content) : false,
@@ -1533,7 +1543,7 @@ describe("ThreadViewport", () => {
});
expect(scroller.scrollTop).toBe(260);
expect(anchor.getBoundingClientRect().top).toBe(120);
expect(replacement.getBoundingClientRect().top).toBe(120);
} finally {
resizeObserver.restore();
}