- 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
Unified · Split
| @@ -309,6 +309,8 @@ import { useAssetUrls } from "../assets/assetUrls"; · 308 unmodified lines | ||
| 309 | 309 | |
| 310 | 310 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 311 | 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. | |
| 313 | +const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); | |
| 312 | 314 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 313 | 315 | // During an active turn the thread's updatedAt advances several times per |
| 314 | 316 | // 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 | 1571 | setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); |
| 1570 | 1572 | } |
| 1571 | 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 | 1596 | const activeRightPanelKind = useRightPanelStore((state) => |
| 1573 | 1597 | selectActiveRightPanel(state.byThreadKey, activeThreadRef), |
| 1574 | 1598 | ); |
| @@ -3479,6 +3503,7 @@ function ChatViewContent(props: ChatViewProps) { · 1904 unmodified lines | ||
| 3479 | 3503 | const anchorScrollRestoreFrameRef = useRef<number | null>(null); |
| 3480 | 3504 | const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { |
| 3481 | 3505 | anchorUserScrollGenerationRef.current += 1; |
| 3506 | + const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling"; | |
| 3482 | 3507 | timelineScrollModeRef.current = "free-scrolling"; |
| 3483 | 3508 | liveFollowUserScrollGenerationRef.current = null; |
| 3484 | 3509 | pendingTimelineAnchorRef.current = null; |
| @@ -3490,6 +3515,30 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines | ||
| 3490 | 3515 | cancelAnimationFrame(anchorScrollRestoreFrameRef.current); |
| 3491 | 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 | 3543 | const cancelTimelineLiveFollowForUserNavigationRef = useRef( |
| 3495 | 3544 | cancelTimelineLiveFollowForUserNavigation, |
| @@ -416,11 +416,10 @@ describe("MessagesTimeline", () => { · 415 unmodified lines | ||
| 416 | 416 | ); |
| 417 | 417 | |
| 418 | 418 | 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"'); | |
| 424 | 423 | expect(markup).toContain('data-user-message-collapsed="true"'); |
| 425 | 424 | expect(markup).toContain('data-user-message-fade="true"'); |
| 426 | 425 | expect(markup).toContain('data-user-message-footer="true"'); |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | type EnvironmentId, |
| 3 | 3 | type MessageId, |
| 4 | − type OrchestrationV2Run, | |
| 5 | 4 | type OrchestrationV2TurnItem, |
| 6 | 5 | type RunAttemptId, |
| 7 | 6 | type ScopedThreadRef, |
| @@ -109,7 +108,7 @@ import { type TimestampFormat } from "@t3tools/contracts/settings"; · 101 unmodified lines | ||
| 109 | 108 | import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; |
| 110 | 109 | import { V2ItemInspector } from "./V2ItemInspector"; |
| 111 | 110 | import { useV2ItemSupport } from "../../state/v2ItemSupport"; |
| 112 | −import { isV2LifecycleItem, V2LifecycleRow } from "./V2LifecycleRow"; | |
| 111 | +import { isV2LifecycleItem, V2LifecycleRow, type HandoffTimelineRun } from "./V2LifecycleRow"; | |
| 113 | 112 | import { TimelineSystemDivider } from "./TimelineSystemDivider"; |
| 114 | 113 | |
| 115 | 114 | import { |
| @@ -144,7 +143,7 @@ interface TimelineRowSharedState { · 28 unmodified lines | ||
| 144 | 143 | /** Provider snapshots for resolving handoff endpoints to icons + model names. */ |
| 145 | 144 | providerStatuses: ReadonlyArray<ServerProvider>; |
| 146 | 145 | /** Projection runs, for recovering handoff models on legacy items. */ |
| 147 | − runs: ReadonlyArray<OrchestrationV2Run>; | |
| 146 | + runs: ReadonlyArray<HandoffTimelineRun>; | |
| 148 | 147 | activeThreadEnvironmentId: EnvironmentId; |
| 149 | 148 | onRevertUserMessage: (messageId: MessageId) => void; |
| 150 | 149 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| @@ -176,7 +175,7 @@ const TIMELINE_LIST_FADE_HEADER = <div className="h-10 sm:h-12" />; · 25 unmodified lines | ||
| 176 | 175 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; |
| 177 | 176 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 178 | 177 | const EMPTY_TIMELINE_PROVIDERS: ReadonlyArray<ServerProvider> = []; |
| 179 | −const EMPTY_TIMELINE_RUNS: ReadonlyArray<OrchestrationV2Run> = []; | |
| 178 | +const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = []; | |
| 180 | 179 | |
| 181 | 180 | // --------------------------------------------------------------------------- |
| 182 | 181 | // Props (public API) |
| @@ -216,7 +215,7 @@ interface MessagesTimelineProps { · 33 unmodified lines | ||
| 216 | 215 | workspaceRoot: string | undefined; |
| 217 | 216 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 218 | 217 | providerStatuses?: ReadonlyArray<ServerProvider>; |
| 219 | − runs?: ReadonlyArray<OrchestrationV2Run>; | |
| 218 | + runs?: ReadonlyArray<HandoffTimelineRun>; | |
| 220 | 219 | anchorMessageId: MessageId | null; |
| 221 | 220 | onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; |
| 222 | 221 | onAnchorSizeChanged: (messageId: MessageId, size: number) => void; |
| @@ -256,7 +255,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 33 unmodified lines | ||
| 256 | 255 | workspaceRoot, |
| 257 | 256 | skills = EMPTY_TIMELINE_SKILLS, |
| 258 | 257 | providerStatuses = EMPTY_TIMELINE_PROVIDERS, |
| 259 | − runs = EMPTY_TIMELINE_RUNS, | |
| 258 | + runs: runsProp = EMPTY_TIMELINE_RUNS, | |
| 260 | 259 | anchorMessageId, |
| 261 | 260 | onAnchorReady, |
| 262 | 261 | onAnchorSizeChanged, |
| @@ -383,6 +382,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 120 unmodified lines | ||
| 383 | 382 | ], |
| 384 | 383 | ); |
| 385 | 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 | 388 | const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); |
| 387 | 389 | const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>( |
| 388 | 390 | null, |
| @@ -581,18 +583,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 192 unmodified lines | ||
| 581 | 583 | initialScrollAtEnd |
| 582 | 584 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 583 | 585 | 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} | |
| 596 | 593 | maintainVisibleContentPosition={maintainVisibleContentPosition} |
| 597 | 594 | onScroll={handleScroll} |
| 598 | 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 | 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. */ | |
| 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 | + | |
| 2137 | 2166 | /** Returns a structurally-shared copy of `rows`: for each row whose content |
| 2138 | 2167 | * hasn't changed since last call, the previous object reference is reused. */ |
| 2139 | 2168 | function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { |
| @@ -55,12 +55,22 @@ const TERMINAL_SUBAGENT_STATUSES = new Set<OrchestrationV2TurnItem["status"]>([ · 54 unmodified lines | ||
| 55 | 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 | + */ | |
| 63 | +export type HandoffTimelineRun = Pick< | |
| 64 | + OrchestrationV2Run, | |
| 65 | + "id" | "ordinal" | "providerInstanceId" | "modelSelection" | |
| 66 | +>; | |
| 67 | + | |
| 58 | 68 | export function V2LifecycleRow(props: { |
| 59 | 69 | readonly item: OrchestrationV2TurnItem; |
| 60 | 70 | readonly createdAt: string; |
| 61 | 71 | readonly timestampFormat: TimestampFormat; |
| 62 | 72 | readonly providerStatuses: ReadonlyArray<ServerProvider>; |
| 63 | − readonly runs: ReadonlyArray<OrchestrationV2Run>; | |
| 73 | + readonly runs: ReadonlyArray<HandoffTimelineRun>; | |
| 64 | 74 | readonly onOpenThread: (threadId: ThreadId) => void; |
| 65 | 75 | }) { |
| 66 | 76 | const { item } = props; |
| @@ -301,11 +311,11 @@ function subagentDisplayTitle(title: string): string { · 234 unmodified lines | ||
| 301 | 311 | * covered runs are still in the projection. |
| 302 | 312 | */ |
| 303 | 313 | function latestRunModelBefore( |
| 304 | − runs: ReadonlyArray<OrchestrationV2Run>, | |
| 314 | + runs: ReadonlyArray<HandoffTimelineRun>, | |
| 305 | 315 | instanceId: ProviderInstanceId, |
| 306 | 316 | beforeOrdinal: number | undefined, |
| 307 | 317 | ): string | undefined { |
| 308 | − let latest: OrchestrationV2Run | undefined; | |
| 318 | + let latest: HandoffTimelineRun | undefined; | |
| 309 | 319 | for (const run of runs) { |
| 310 | 320 | if (run.providerInstanceId !== instanceId) continue; |
| 311 | 321 | if (beforeOrdinal !== undefined && run.ordinal >= beforeOrdinal) continue; |