History

fix(chat): prevent stale timeline scroll and rerenders

- Disable LegendList end-maintenance that could snap after user scrolling - Cancel stale imperative scrolls and release settled turn anchors - Stabilize handoff run data to avoid per-event timeline rerenders

Julius Marminge committed 3 months ago

Commit f29edaf1 parent b76a0914 files · +113 −26Browse files

Unified · Split

4 files
  1. apps/web/src/components/ChatView.tsx +49 −0
  2. apps/web/src/components/chat/MessagesTimeline.test.tsx +4 −5
  3. apps/web/src/components/chat/MessagesTimeline.tsx +47 −18
  4. apps/web/src/components/chat/V2LifecycleRow.tsx +13 −3
apps/web/src/components/ChatView.tsx+49−0
@@ -309,6 +309,8 @@ import { useAssetUrls } from "../assets/assetUrls"; · 308 unmodified lines
309 309
310const IMAGE_ONLY_BOOTSTRAP_PROMPT =310const IMAGE_ONLY_BOOTSTRAP_PROMPT =
311 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";311 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
312// Never part of timeline row data — see cancelTimelineLiveFollowForUserNavigation.
313const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({});
312const EMPTY_PROVIDERS: ServerProvider[] = [];314const EMPTY_PROVIDERS: ServerProvider[] = [];
313// During an active turn the thread's updatedAt advances several times per315// During an active turn the thread's updatedAt advances several times per
314// second, and every server-side visit is a full command dispatch plus a316// second, and every server-side visit is a full command dispatch plus a
@@ -1569,6 +1571,28 @@ function ChatViewContent(props: ChatViewProps) { · 1254 unmodified lines
1569 setTimelineAnchor({ threadKey: activeThreadKey, messageId: null });1571 setTimelineAnchor({ threadKey: activeThreadKey, messageId: null });
1570 }1572 }
1571 const timelineAnchorMessageId = timelineAnchor.messageId;1573 const timelineAnchorMessageId = timelineAnchor.messageId;
1574 // Release the turn anchor once its run finishes. LegendList sizes the
1575 // anchored end-space filler for the geometry at anchor time and never
1576 // recomputes it, so a lingering anchor leaves stale filler height behind
1577 // the composer — phantom scroll range on threads whose content fits.
1578 const anchorRunSettled = useMemo(() => {
1579 if (timelineAnchorMessageId === null) return false;
1580 const anchorRun = serverProjection?.runs.find(
1581 (run) => run.userMessageId === timelineAnchorMessageId,
1582 );
1583 if (anchorRun === undefined) return false;
1584 return (
1585 anchorRun.status !== "preparing" &&
1586 anchorRun.status !== "queued" &&
1587 anchorRun.status !== "starting" &&
1588 anchorRun.status !== "running" &&
1589 anchorRun.status !== "waiting"
1590 );
1591 }, [serverProjection, timelineAnchorMessageId]);
1592 useEffect(() => {
1593 if (!anchorRunSettled) return;
1594 setTimelineAnchor({ threadKey: activeThreadKey, messageId: null });
1595 }, [anchorRunSettled, activeThreadKey]);
1572 const activeRightPanelKind = useRightPanelStore((state) =>1596 const activeRightPanelKind = useRightPanelStore((state) =>
1573 selectActiveRightPanel(state.byThreadKey, activeThreadRef),1597 selectActiveRightPanel(state.byThreadKey, activeThreadRef),
1574 );1598 );
@@ -3479,6 +3503,7 @@ function ChatViewContent(props: ChatViewProps) { · 1904 unmodified lines
3479 const anchorScrollRestoreFrameRef = useRef<number | null>(null);3503 const anchorScrollRestoreFrameRef = useRef<number | null>(null);
3480 const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {3504 const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
3481 anchorUserScrollGenerationRef.current += 1;3505 anchorUserScrollGenerationRef.current += 1;
3506 const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling";
3482 timelineScrollModeRef.current = "free-scrolling";3507 timelineScrollModeRef.current = "free-scrolling";
3483 liveFollowUserScrollGenerationRef.current = null;3508 liveFollowUserScrollGenerationRef.current = null;
3484 pendingTimelineAnchorRef.current = null;3509 pendingTimelineAnchorRef.current = null;
@@ -3490,6 +3515,30 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines
3490 cancelAnimationFrame(anchorScrollRestoreFrameRef.current);3515 cancelAnimationFrame(anchorScrollRestoreFrameRef.current);
3491 anchorScrollRestoreFrameRef.current = null;3516 anchorScrollRestoreFrameRef.current = null;
3492 }3517 }
3518 if (wasProgrammaticScrollMode) {
3519 // While following or anchoring, our scrollToEnd/scrollToOffset calls can
3520 // sit in LegendList's pending-imperative-scroll queue (it defers them
3521 // while layout settles) and fire seconds later with stale targets,
3522 // yanking the view away after the user scrolled. Starting a new
3523 // imperative scroll cancels everything queued; targeting an item that
3524 // is not in the data makes the new request itself resolve without ever
3525 // scrolling, so this is a pure cancel.
3526 void legendListRef.current?.scrollToItem({
3527 item: TIMELINE_SCROLL_CANCEL_SENTINEL,
3528 animated: false,
3529 });
3530 // An already-started animated scroll (behavior: smooth) keeps running in
3531 // the browser regardless of the queue; a same-position instant write is
3532 // the only way to halt it where it is.
3533 const scrollNode = legendListRef.current?.getScrollableNode() as
3534 | { scrollTop?: number }
3535 | null
3536 | undefined;
3537 const currentScrollTop = scrollNode?.scrollTop;
3538 if (scrollNode && typeof currentScrollTop === "number") {
3539 scrollNode.scrollTop = currentScrollTop;
3540 }
3541 }
3493 }, []);3542 }, []);
3494 const cancelTimelineLiveFollowForUserNavigationRef = useRef(3543 const cancelTimelineLiveFollowForUserNavigationRef = useRef(
3495 cancelTimelineLiveFollowForUserNavigation,3544 cancelTimelineLiveFollowForUserNavigation,
apps/web/src/components/chat/MessagesTimeline.test.tsx+4−5
@@ -416,11 +416,10 @@ describe("MessagesTimeline", () => { · 415 unmodified lines
416 );416 );
417 417
418 expect(markup).toContain("Show full message");418 expect(markup).toContain("Show full message");
419 expect(markup).toContain('data-maintain-scroll-at-end="enabled"');419 // LegendList's internal maintainScrollAtEnd stays off: it races post-mount
420 expect(markup).toContain('data-maintain-scroll-at-end-animated="false"');420 // measurement and snaps the view to stale content ends. End-following is
421 expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"');421 // owned by ChatView's live-follow, which respects user scroll gestures.
422 expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"');422 expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
423 expect(markup).toContain('data-maintain-scroll-at-end-layout="true"');
424 expect(markup).toContain('data-user-message-collapsed="true"');423 expect(markup).toContain('data-user-message-collapsed="true"');
425 expect(markup).toContain('data-user-message-fade="true"');424 expect(markup).toContain('data-user-message-fade="true"');
426 expect(markup).toContain('data-user-message-footer="true"');425 expect(markup).toContain('data-user-message-footer="true"');
apps/web/src/components/chat/MessagesTimeline.tsx+47−18
@@ -1,7 +1,6 @@
1import {1import {
2 type EnvironmentId,2 type EnvironmentId,
3 type MessageId,3 type MessageId,
4 type OrchestrationV2Run,
5 type OrchestrationV2TurnItem,4 type OrchestrationV2TurnItem,
6 type RunAttemptId,5 type RunAttemptId,
7 type ScopedThreadRef,6 type ScopedThreadRef,
@@ -109,7 +108,7 @@ import { type TimestampFormat } from "@t3tools/contracts/settings"; · 101 unmodified lines
109import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";108import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";
110import { V2ItemInspector } from "./V2ItemInspector";109import { V2ItemInspector } from "./V2ItemInspector";
111import { useV2ItemSupport } from "../../state/v2ItemSupport";110import { useV2ItemSupport } from "../../state/v2ItemSupport";
112import { isV2LifecycleItem, V2LifecycleRow } from "./V2LifecycleRow";111import { isV2LifecycleItem, V2LifecycleRow, type HandoffTimelineRun } from "./V2LifecycleRow";
113import { TimelineSystemDivider } from "./TimelineSystemDivider";112import { TimelineSystemDivider } from "./TimelineSystemDivider";
114 113
115import {114import {
@@ -144,7 +143,7 @@ interface TimelineRowSharedState { · 28 unmodified lines
144 /** Provider snapshots for resolving handoff endpoints to icons + model names. */143 /** Provider snapshots for resolving handoff endpoints to icons + model names. */
145 providerStatuses: ReadonlyArray<ServerProvider>;144 providerStatuses: ReadonlyArray<ServerProvider>;
146 /** Projection runs, for recovering handoff models on legacy items. */145 /** Projection runs, for recovering handoff models on legacy items. */
147 runs: ReadonlyArray<OrchestrationV2Run>;146 runs: ReadonlyArray<HandoffTimelineRun>;
148 activeThreadEnvironmentId: EnvironmentId;147 activeThreadEnvironmentId: EnvironmentId;
149 onRevertUserMessage: (messageId: MessageId) => void;148 onRevertUserMessage: (messageId: MessageId) => void;
150 onImageExpand: (preview: ExpandedImagePreview) => void;149 onImageExpand: (preview: ExpandedImagePreview) => void;
@@ -176,7 +175,7 @@ const TIMELINE_LIST_FADE_HEADER = <div className="h-10 sm:h-12" />; · 25 unmodified lines
176const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;175const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
177const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];176const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
178const EMPTY_TIMELINE_PROVIDERS: ReadonlyArray<ServerProvider> = [];177const EMPTY_TIMELINE_PROVIDERS: ReadonlyArray<ServerProvider> = [];
179const EMPTY_TIMELINE_RUNS: ReadonlyArray<OrchestrationV2Run> = [];178const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = [];
180 179
181// ---------------------------------------------------------------------------180// ---------------------------------------------------------------------------
182// Props (public API)181// Props (public API)
@@ -216,7 +215,7 @@ interface MessagesTimelineProps { · 33 unmodified lines
216 workspaceRoot: string | undefined;215 workspaceRoot: string | undefined;
217 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;216 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
218 providerStatuses?: ReadonlyArray<ServerProvider>;217 providerStatuses?: ReadonlyArray<ServerProvider>;
219 runs?: ReadonlyArray<OrchestrationV2Run>;218 runs?: ReadonlyArray<HandoffTimelineRun>;
220 anchorMessageId: MessageId | null;219 anchorMessageId: MessageId | null;
221 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;220 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
222 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;221 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
@@ -256,7 +255,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 33 unmodified lines
256 workspaceRoot,255 workspaceRoot,
257 skills = EMPTY_TIMELINE_SKILLS,256 skills = EMPTY_TIMELINE_SKILLS,
258 providerStatuses = EMPTY_TIMELINE_PROVIDERS,257 providerStatuses = EMPTY_TIMELINE_PROVIDERS,
259 runs = EMPTY_TIMELINE_RUNS,258 runs: runsProp = EMPTY_TIMELINE_RUNS,
260 anchorMessageId,259 anchorMessageId,
261 onAnchorReady,260 onAnchorReady,
262 onAnchorSizeChanged,261 onAnchorSizeChanged,
@@ -383,6 +382,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 120 unmodified lines
383 ],382 ],
384 );383 );
385 const rows = useStableRows(rawRows);384 const rows = useStableRows(rawRows);
385 // Run status/timestamps churn on every stream event; the shared row context
386 // must not change with them or every timeline row re-renders per event.
387 const runs = useStableHandoffRuns(runsProp);
386 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);388 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
387 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(389 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
388 null,390 null,
@@ -581,18 +583,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 192 unmodified lines
581 initialScrollAtEnd583 initialScrollAtEnd
582 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}584 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
583 contentInsetEndAdjustment={contentInsetEndAdjustment}585 contentInsetEndAdjustment={contentInsetEndAdjustment}
584 maintainScrollAtEnd={586 // The app owns end-following (ChatView live-follow + scroll-to-end
585 anchoredEndSpace587 // pill), which respects the user's scroll gestures. LegendList's
586 ? false588 // internal maintainScrollAtEnd races post-mount measurement: it
587 : {589 // caches its at-end flag while a maintain cycle is active, so
588 animated: false,590 // overlapping item-layout reconciliations keep snapping the view
589 on: {591 // to stale content ends even after the user scrolled away.
590 dataChange: true,592 maintainScrollAtEnd={false}
591 itemLayout: true,
592 layout: true,
593 },
594 }
595 }
596 maintainVisibleContentPosition={maintainVisibleContentPosition}593 maintainVisibleContentPosition={maintainVisibleContentPosition}
597 onScroll={handleScroll}594 onScroll={handleScroll}
598 className="messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]"595 className="messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]"
@@ -2134,6 +2131,38 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte · 1535 unmodified lines
2134// so LegendList (and React) can skip re-rendering unchanged items.2131// so LegendList (and React) can skip re-rendering unchanged items.
2135// ---------------------------------------------------------------------------2132// ---------------------------------------------------------------------------
2136 2133
2134/** Content-stable projection of the runs the handoff rows read. The incoming
2135 * array is rebuilt on every projection event (status/timestamp churn), but
2136 * the returned reference only changes when a run's identity-relevant fields
2137 * (id, ordinal, instance, model) do — keeping TimelineRowCtx stable. */
2138function useStableHandoffRuns(
2139 runs: ReadonlyArray<HandoffTimelineRun>,
2140): ReadonlyArray<HandoffTimelineRun> {
2141 const prev = useRef<{
2142 signature: string;
2143 value: ReadonlyArray<HandoffTimelineRun>;
2144 }>({ signature: "", value: EMPTY_TIMELINE_RUNS });
2145 return useMemo(() => {
2146 const signature = runs
2147 .map(
2148 (run) =>
2149 `${run.id}\0${run.providerInstanceId}\0${run.ordinal}\0${run.modelSelection.instanceId}\0${run.modelSelection.model}`,
2150 )
2151 .join("\n");
2152 if (signature === prev.current.signature) {
2153 return prev.current.value;
2154 }
2155 const value = runs.map((run) => ({
2156 id: run.id,
2157 ordinal: run.ordinal,
2158 providerInstanceId: run.providerInstanceId,
2159 modelSelection: run.modelSelection,
2160 }));
2161 prev.current = { signature, value };
2162 return value;
2163 }, [runs]);
2164}
2165
2137/** Returns a structurally-shared copy of `rows`: for each row whose content2166/** Returns a structurally-shared copy of `rows`: for each row whose content
2138 * hasn't changed since last call, the previous object reference is reused. */2167 * hasn't changed since last call, the previous object reference is reused. */
2139function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {2168function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {
apps/web/src/components/chat/V2LifecycleRow.tsx+13−3
@@ -55,12 +55,22 @@ const TERMINAL_SUBAGENT_STATUSES = new Set<OrchestrationV2TurnItem["status"]>([ · 54 unmodified lines
55 "interrupted",55 "interrupted",
56]);56]);
57 57
58/**
59 * The subset of a projection run that handoff rows read. Kept minimal so the
60 * timeline can hold a content-stable snapshot: run status/timestamps churn on
61 * every stream event, but these fields only change when a run is added.
62 */
63export type HandoffTimelineRun = Pick<
64 OrchestrationV2Run,
65 "id" | "ordinal" | "providerInstanceId" | "modelSelection"
66>;
67
58export function V2LifecycleRow(props: {68export function V2LifecycleRow(props: {
59 readonly item: OrchestrationV2TurnItem;69 readonly item: OrchestrationV2TurnItem;
60 readonly createdAt: string;70 readonly createdAt: string;
61 readonly timestampFormat: TimestampFormat;71 readonly timestampFormat: TimestampFormat;
62 readonly providerStatuses: ReadonlyArray<ServerProvider>;72 readonly providerStatuses: ReadonlyArray<ServerProvider>;
63 readonly runs: ReadonlyArray<OrchestrationV2Run>;73 readonly runs: ReadonlyArray<HandoffTimelineRun>;
64 readonly onOpenThread: (threadId: ThreadId) => void;74 readonly onOpenThread: (threadId: ThreadId) => void;
65}) {75}) {
66 const { item } = props;76 const { item } = props;
@@ -301,11 +311,11 @@ function subagentDisplayTitle(title: string): string { · 234 unmodified lines
301 * covered runs are still in the projection.311 * covered runs are still in the projection.
302 */312 */
303function latestRunModelBefore(313function latestRunModelBefore(
304 runs: ReadonlyArray<OrchestrationV2Run>,314 runs: ReadonlyArray<HandoffTimelineRun>,
305 instanceId: ProviderInstanceId,315 instanceId: ProviderInstanceId,
306 beforeOrdinal: number | undefined,316 beforeOrdinal: number | undefined,
307): string | undefined {317): string | undefined {
308 let latest: OrchestrationV2Run | undefined;318 let latest: HandoffTimelineRun | undefined;
309 for (const run of runs) {319 for (const run of runs) {
310 if (run.providerInstanceId !== instanceId) continue;320 if (run.providerInstanceId !== instanceId) continue;
311 if (beforeOrdinal !== undefined && run.ordinal >= beforeOrdinal) continue;321 if (beforeOrdinal !== undefined && run.ordinal >= beforeOrdinal) continue;