- 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 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 310 | const 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. | ||
| 313 | const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); | ||
| 312 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 314 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 313 | // During an active turn the thread's updatedAt advances several times per | 315 | // 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 a | 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 | 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, |
| @@ -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"'); |
| @@ -1,7 +1,6 @@ | |||
| 1 | import { | 1 | import { |
| 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 | |||
| 109 | import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; | 108 | import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; |
| 110 | import { V2ItemInspector } from "./V2ItemInspector"; | 109 | import { V2ItemInspector } from "./V2ItemInspector"; |
| 111 | import { useV2ItemSupport } from "../../state/v2ItemSupport"; | 110 | import { useV2ItemSupport } from "../../state/v2ItemSupport"; |
| 112 | import { isV2LifecycleItem, V2LifecycleRow } from "./V2LifecycleRow"; | 111 | import { isV2LifecycleItem, V2LifecycleRow, type HandoffTimelineRun } from "./V2LifecycleRow"; |
| 113 | import { TimelineSystemDivider } from "./TimelineSystemDivider"; | 112 | import { TimelineSystemDivider } from "./TimelineSystemDivider"; |
| 114 | 113 | ||
| 115 | import { | 114 | import { |
| @@ -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 | |||
| 176 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; | 175 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; |
| 177 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; | 176 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 178 | const EMPTY_TIMELINE_PROVIDERS: ReadonlyArray<ServerProvider> = []; | 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 | // 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 | initialScrollAtEnd | 583 | 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 | anchoredEndSpace | 587 | // pill), which respects the user's scroll gestures. LegendList's |
| 586 | ? false | 588 | // 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. */ | ||
| 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 | /** Returns a structurally-shared copy of `rows`: for each row whose content | 2166 | /** 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. */ |
| 2139 | function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { | 2168 | function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { |
| @@ -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 | */ | ||
| 63 | export type HandoffTimelineRun = Pick< | ||
| 64 | OrchestrationV2Run, | ||
| 65 | "id" | "ordinal" | "providerInstanceId" | "modelSelection" | ||
| 66 | >; | ||
| 67 | |||
| 58 | export function V2LifecycleRow(props: { | 68 | export 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 | */ |
| 303 | function latestRunModelBefore( | 313 | function 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; |