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
309309
310310 const IMAGE_ONLY_BOOTSTRAP_PROMPT =
311311 "[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.
313+const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({});
312314 const EMPTY_PROVIDERS: ServerProvider[] = [];
313315 // During an active turn the thread's updatedAt advances several times per
314316 // second, and every server-side visit is a full command dispatch plus a
@@ -1569,6 +1571,28 @@ function ChatViewContent(props: ChatViewProps) { · 1254 unmodified lines
15691571 setTimelineAnchor({ threadKey: activeThreadKey, messageId: null });
15701572 }
15711573 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]);
15721596 const activeRightPanelKind = useRightPanelStore((state) =>
15731597 selectActiveRightPanel(state.byThreadKey, activeThreadRef),
15741598 );
@@ -3479,6 +3503,7 @@ function ChatViewContent(props: ChatViewProps) { · 1904 unmodified lines
34793503 const anchorScrollRestoreFrameRef = useRef<number | null>(null);
34803504 const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
34813505 anchorUserScrollGenerationRef.current += 1;
3506+ const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling";
34823507 timelineScrollModeRef.current = "free-scrolling";
34833508 liveFollowUserScrollGenerationRef.current = null;
34843509 pendingTimelineAnchorRef.current = null;
@@ -3490,6 +3515,30 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines
34903515 cancelAnimationFrame(anchorScrollRestoreFrameRef.current);
34913516 anchorScrollRestoreFrameRef.current = null;
34923517 }
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+ }
34933542 }, []);
34943543 const cancelTimelineLiveFollowForUserNavigationRef = useRef(
34953544 cancelTimelineLiveFollowForUserNavigation,
apps/web/src/components/chat/MessagesTimeline.test.tsx+4−5
@@ -416,11 +416,10 @@ describe("MessagesTimeline", () => { · 415 unmodified lines
416416 );
417417
418418 expect(markup).toContain("Show full message");
419− expect(markup).toContain('data-maintain-scroll-at-end="enabled"');
420− expect(markup).toContain('data-maintain-scroll-at-end-animated="false"');
421− expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"');
422− expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"');
423− expect(markup).toContain('data-maintain-scroll-at-end-layout="true"');
419+ // LegendList's internal maintainScrollAtEnd stays off: it races post-mount
420+ // measurement and snaps the view to stale content ends. End-following is
421+ // owned by ChatView's live-follow, which respects user scroll gestures.
422+ expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
424423 expect(markup).toContain('data-user-message-collapsed="true"');
425424 expect(markup).toContain('data-user-message-fade="true"');
426425 expect(markup).toContain('data-user-message-footer="true"');
apps/web/src/components/chat/MessagesTimeline.tsx+47−18
@@ -1,7 +1,6 @@
11 import {
22 type EnvironmentId,
33 type MessageId,
4− type OrchestrationV2Run,
54 type OrchestrationV2TurnItem,
65 type RunAttemptId,
76 type ScopedThreadRef,
@@ -109,7 +108,7 @@ import { type TimestampFormat } from "@t3tools/contracts/settings"; · 101 unmodified lines
109108 import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";
110109 import { V2ItemInspector } from "./V2ItemInspector";
111110 import { useV2ItemSupport } from "../../state/v2ItemSupport";
112−import { isV2LifecycleItem, V2LifecycleRow } from "./V2LifecycleRow";
111+import { isV2LifecycleItem, V2LifecycleRow, type HandoffTimelineRun } from "./V2LifecycleRow";
113112 import { TimelineSystemDivider } from "./TimelineSystemDivider";
114113
115114 import {
@@ -144,7 +143,7 @@ interface TimelineRowSharedState { · 28 unmodified lines
144143 /** Provider snapshots for resolving handoff endpoints to icons + model names. */
145144 providerStatuses: ReadonlyArray<ServerProvider>;
146145 /** Projection runs, for recovering handoff models on legacy items. */
147− runs: ReadonlyArray<OrchestrationV2Run>;
146+ runs: ReadonlyArray<HandoffTimelineRun>;
148147 activeThreadEnvironmentId: EnvironmentId;
149148 onRevertUserMessage: (messageId: MessageId) => void;
150149 onImageExpand: (preview: ExpandedImagePreview) => void;
@@ -176,7 +175,7 @@ const TIMELINE_LIST_FADE_HEADER = <div className="h-10 sm:h-12" />; · 25 unmodified lines
176175 const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
177176 const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
178177 const EMPTY_TIMELINE_PROVIDERS: ReadonlyArray<ServerProvider> = [];
179−const EMPTY_TIMELINE_RUNS: ReadonlyArray<OrchestrationV2Run> = [];
178+const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = [];
180179
181180 // ---------------------------------------------------------------------------
182181 // Props (public API)
@@ -216,7 +215,7 @@ interface MessagesTimelineProps { · 33 unmodified lines
216215 workspaceRoot: string | undefined;
217216 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
218217 providerStatuses?: ReadonlyArray<ServerProvider>;
219− runs?: ReadonlyArray<OrchestrationV2Run>;
218+ runs?: ReadonlyArray<HandoffTimelineRun>;
220219 anchorMessageId: MessageId | null;
221220 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
222221 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
@@ -256,7 +255,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 33 unmodified lines
256255 workspaceRoot,
257256 skills = EMPTY_TIMELINE_SKILLS,
258257 providerStatuses = EMPTY_TIMELINE_PROVIDERS,
259− runs = EMPTY_TIMELINE_RUNS,
258+ runs: runsProp = EMPTY_TIMELINE_RUNS,
260259 anchorMessageId,
261260 onAnchorReady,
262261 onAnchorSizeChanged,
@@ -383,6 +382,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 120 unmodified lines
383382 ],
384383 );
385384 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);
386388 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
387389 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
388390 null,
@@ -581,18 +583,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 192 unmodified lines
581583 initialScrollAtEnd
582584 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
583585 contentInsetEndAdjustment={contentInsetEndAdjustment}
584− maintainScrollAtEnd={
585− anchoredEndSpace
586− ? false
587− : {
588− animated: false,
589− on: {
590− dataChange: true,
591− itemLayout: true,
592− layout: true,
593− },
594− }
595− }
586+ // The app owns end-following (ChatView live-follow + scroll-to-end
587+ // pill), which respects the user's scroll gestures. LegendList's
588+ // internal maintainScrollAtEnd races post-mount measurement: it
589+ // caches its at-end flag while a maintain cycle is active, so
590+ // overlapping item-layout reconciliations keep snapping the view
591+ // to stale content ends even after the user scrolled away.
592+ maintainScrollAtEnd={false}
596593 maintainVisibleContentPosition={maintainVisibleContentPosition}
597594 onScroll={handleScroll}
598595 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
21342131 // so LegendList (and React) can skip re-rendering unchanged items.
21352132 // ---------------------------------------------------------------------------
21362133
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. */
2138+function 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+
21372166 /** Returns a structurally-shared copy of `rows`: for each row whose content
21382167 * hasn't changed since last call, the previous object reference is reused. */
21392168 function 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
5555 "interrupted",
5656 ]);
5757
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+ */
63+export type HandoffTimelineRun = Pick<
64+ OrchestrationV2Run,
65+ "id" | "ordinal" | "providerInstanceId" | "modelSelection"
66+>;
67+
5868 export function V2LifecycleRow(props: {
5969 readonly item: OrchestrationV2TurnItem;
6070 readonly createdAt: string;
6171 readonly timestampFormat: TimestampFormat;
6272 readonly providerStatuses: ReadonlyArray<ServerProvider>;
63− readonly runs: ReadonlyArray<OrchestrationV2Run>;
73+ readonly runs: ReadonlyArray<HandoffTimelineRun>;
6474 readonly onOpenThread: (threadId: ThreadId) => void;
6575 }) {
6676 const { item } = props;
@@ -301,11 +311,11 @@ function subagentDisplayTitle(title: string): string { · 234 unmodified lines
301311 * covered runs are still in the projection.
302312 */
303313 function latestRunModelBefore(
304− runs: ReadonlyArray<OrchestrationV2Run>,
314+ runs: ReadonlyArray<HandoffTimelineRun>,
305315 instanceId: ProviderInstanceId,
306316 beforeOrdinal: number | undefined,
307317 ): string | undefined {
308− let latest: OrchestrationV2Run | undefined;
318+ let latest: HandoffTimelineRun | undefined;
309319 for (const run of runs) {
310320 if (run.providerInstanceId !== instanceId) continue;
311321 if (beforeOrdinal !== undefined && run.ordinal >= beforeOrdinal) continue;