Unified · Split
| @@ -5,8 +5,68 @@ import { · 4 unmodified lines | |||
| 5 | deriveMessagesTimelineRows, | 5 | deriveMessagesTimelineRows, |
| 6 | normalizeCompactToolLabel, | 6 | normalizeCompactToolLabel, |
| 7 | resolveAssistantMessageCopyState, | 7 | resolveAssistantMessageCopyState, |
| 8 | resolveOlderHistoryAutoLoad, | ||
| 8 | } from "./MessagesTimeline.logic"; | 9 | } from "./MessagesTimeline.logic"; |
| 9 | 10 | ||
| 11 | describe("resolveOlderHistoryAutoLoad", () => { | ||
| 12 | it("dispatches only once while a failed or successful load leaves the viewport at the top", () => { | ||
| 13 | const first = resolveOlderHistoryAutoLoad({ | ||
| 14 | isAtStart: true, | ||
| 15 | hasMoreOlder: true, | ||
| 16 | loadingOlder: false, | ||
| 17 | requestedAtStart: false, | ||
| 18 | }); | ||
| 19 | expect(first).toEqual({ requestedAtStart: true, shouldLoad: true }); | ||
| 20 | |||
| 21 | expect( | ||
| 22 | resolveOlderHistoryAutoLoad({ | ||
| 23 | isAtStart: true, | ||
| 24 | hasMoreOlder: true, | ||
| 25 | loadingOlder: false, | ||
| 26 | requestedAtStart: first.requestedAtStart, | ||
| 27 | }), | ||
| 28 | ).toEqual({ requestedAtStart: true, shouldLoad: false }); | ||
| 29 | expect( | ||
| 30 | resolveOlderHistoryAutoLoad({ | ||
| 31 | isAtStart: true, | ||
| 32 | hasMoreOlder: true, | ||
| 33 | loadingOlder: true, | ||
| 34 | requestedAtStart: first.requestedAtStart, | ||
| 35 | }), | ||
| 36 | ).toEqual({ requestedAtStart: true, shouldLoad: false }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("rearms automatic loading only after leaving the top", () => { | ||
| 40 | const away = resolveOlderHistoryAutoLoad({ | ||
| 41 | isAtStart: false, | ||
| 42 | hasMoreOlder: true, | ||
| 43 | loadingOlder: false, | ||
| 44 | requestedAtStart: true, | ||
| 45 | }); | ||
| 46 | expect(away).toEqual({ requestedAtStart: false, shouldLoad: false }); | ||
| 47 | |||
| 48 | expect( | ||
| 49 | resolveOlderHistoryAutoLoad({ | ||
| 50 | isAtStart: true, | ||
| 51 | hasMoreOlder: true, | ||
| 52 | loadingOlder: false, | ||
| 53 | requestedAtStart: away.requestedAtStart, | ||
| 54 | }), | ||
| 55 | ).toEqual({ requestedAtStart: true, shouldLoad: true }); | ||
| 56 | }); | ||
| 57 | |||
| 58 | it("does not reset the latch before list state is available", () => { | ||
| 59 | expect( | ||
| 60 | resolveOlderHistoryAutoLoad({ | ||
| 61 | isAtStart: undefined, | ||
| 62 | hasMoreOlder: true, | ||
| 63 | loadingOlder: false, | ||
| 64 | requestedAtStart: true, | ||
| 65 | }), | ||
| 66 | ).toEqual({ requestedAtStart: true, shouldLoad: false }); | ||
| 67 | }); | ||
| 68 | }); | ||
| 69 | |||
| 10 | describe("computeMessageDurationStart", () => { | 70 | describe("computeMessageDurationStart", () => { |
| 11 | it("returns message createdAt when there is no preceding user message", () => { | 71 | it("returns message createdAt when there is no preceding user message", () => { |
| 12 | const result = computeMessageDurationStart([ | 72 | const result = computeMessageDurationStart([ |
| @@ -21,6 +21,42 @@ export interface TimelineEndState { · 20 unmodified lines | |||
| 21 | readonly isNearEnd?: boolean; | 21 | readonly isNearEnd?: boolean; |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | export interface OlderHistoryAutoLoadInput { | ||
| 25 | readonly isAtStart: boolean | undefined; | ||
| 26 | readonly hasMoreOlder: boolean; | ||
| 27 | readonly loadingOlder: boolean; | ||
| 28 | readonly requestedAtStart: boolean; | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface OlderHistoryAutoLoadResult { | ||
| 32 | readonly requestedAtStart: boolean; | ||
| 33 | readonly shouldLoad: boolean; | ||
| 34 | } | ||
| 35 | |||
| 36 | /** | ||
| 37 | * Permit at most one automatic older-history request per visit to the top. | ||
| 38 | * Loading completion and prepended rows both retrigger timeline measurements; | ||
| 39 | * neither should recursively request another page while the viewport remains | ||
| 40 | * pinned at the start. Leaving the top rearms automatic loading, while the | ||
| 41 | * explicit header control remains available for an immediate retry. | ||
| 42 | */ | ||
| 43 | export function resolveOlderHistoryAutoLoad( | ||
| 44 | input: OlderHistoryAutoLoadInput, | ||
| 45 | ): OlderHistoryAutoLoadResult { | ||
| 46 | if (input.isAtStart === false) { | ||
| 47 | return { requestedAtStart: false, shouldLoad: false }; | ||
| 48 | } | ||
| 49 | if ( | ||
| 50 | input.isAtStart !== true || | ||
| 51 | input.requestedAtStart || | ||
| 52 | !input.hasMoreOlder || | ||
| 53 | input.loadingOlder | ||
| 54 | ) { | ||
| 55 | return { requestedAtStart: input.requestedAtStart, shouldLoad: false }; | ||
| 56 | } | ||
| 57 | return { requestedAtStart: true, shouldLoad: true }; | ||
| 58 | } | ||
| 59 | |||
| 24 | export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { | 60 | export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { |
| 25 | return state?.isNearEnd ?? state?.isAtEnd; | 61 | return state?.isNearEnd ?? state?.isAtEnd; |
| 26 | } | 62 | } |
| @@ -73,6 +73,7 @@ import { · 72 unmodified lines | |||
| 73 | deriveMessagesTimelineRows, | 73 | deriveMessagesTimelineRows, |
| 74 | normalizeCompactToolLabel, | 74 | normalizeCompactToolLabel, |
| 75 | resolveAssistantMessageCopyState, | 75 | resolveAssistantMessageCopyState, |
| 76 | resolveOlderHistoryAutoLoad, | ||
| 76 | resolveTimelineIsAtEnd, | 77 | resolveTimelineIsAtEnd, |
| 77 | resolveTimelineMinimapHasPersistentGutter, | 78 | resolveTimelineMinimapHasPersistentGutter, |
| 78 | resolveTimelineMinimapHeightStyle, | 79 | resolveTimelineMinimapHeightStyle, |
| @@ -337,6 +338,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 258 unmodified lines | |||
| 337 | ); | 338 | ); |
| 338 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); | 339 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); |
| 339 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); | 340 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); |
| 341 | const olderHistoryRequestedAtStartRef = useRef(false); | ||
| 342 | useEffect(() => { | ||
| 343 | olderHistoryRequestedAtStartRef.current = false; | ||
| 344 | }, [routeThreadKey]); | ||
| 345 | const requestOlderHistoryAtStart = useCallback(() => { | ||
| 346 | olderHistoryRequestedAtStartRef.current = true; | ||
| 347 | onLoadOlder?.(); | ||
| 348 | }, [onLoadOlder]); | ||
| 340 | const handleAnchorReady = useCallback( | 349 | const handleAnchorReady = useCallback( |
| 341 | (info: { anchorIndex: number | undefined }) => { | 350 | (info: { anchorIndex: number | undefined }) => { |
| 342 | if (anchorMessageId !== null && info.anchorIndex !== undefined) { | 351 | if (anchorMessageId !== null && info.anchorIndex !== undefined) { |
| @@ -368,10 +377,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 25 unmodified lines | |||
| 368 | if (isAtEnd !== undefined) { | 377 | if (isAtEnd !== undefined) { |
| 369 | onIsAtEndChange(isAtEnd); | 378 | onIsAtEndChange(isAtEnd); |
| 370 | } | 379 | } |
| 371 | // Reaching the top lazy-loads older history; maintainVisibleContentPosition | 380 | // Reaching the top lazy-loads one older page. Measurements rerun when the |
| 372 | // (set on the list) keeps the viewport anchored when rows prepend. | 381 | // request settles and when rows prepend, so keep the visit latched until |
| 373 | if (state?.isAtStart && hasMoreOlder && !loadingOlder) { | 382 | // the user leaves the top; otherwise failures retry forever and successful |
| 374 | onLoadOlder?.(); | 383 | // pages chain-load the whole history back into memory. |
| 384 | const olderHistoryAutoLoad = resolveOlderHistoryAutoLoad({ | ||
| 385 | isAtStart: state?.isAtStart, | ||
| 386 | hasMoreOlder: hasMoreOlder && onLoadOlder !== undefined, | ||
| 387 | loadingOlder, | ||
| 388 | requestedAtStart: olderHistoryRequestedAtStartRef.current, | ||
| 389 | }); | ||
| 390 | olderHistoryRequestedAtStartRef.current = olderHistoryAutoLoad.requestedAtStart; | ||
| 391 | if (olderHistoryAutoLoad.shouldLoad) { | ||
| 392 | requestOlderHistoryAtStart(); | ||
| 375 | } | 393 | } |
| 376 | if (!state || minimapItems.length === 0) { | 394 | if (!state || minimapItems.length === 0) { |
| 377 | return; | 395 | return; |
| @@ -403,6 +421,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 25 unmodified lines | |||
| 403 | hasMoreOlder, | 421 | hasMoreOlder, |
| 404 | loadingOlder, | 422 | loadingOlder, |
| 405 | onLoadOlder, | 423 | onLoadOlder, |
| 424 | requestOlderHistoryAtStart, | ||
| 406 | ]); | 425 | ]); |
| 407 | 426 | ||
| 408 | useEffect(() => { | 427 | useEffect(() => { |
| @@ -447,7 +466,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 38 unmodified lines | |||
| 447 | return ( | 466 | return ( |
| 448 | <button | 467 | <button |
| 449 | type="button" | 468 | type="button" |
| 450 | onClick={onLoadOlder} | 469 | onClick={requestOlderHistoryAtStart} |
| 451 | className="flex w-full cursor-pointer items-center justify-center py-2 text-xs text-muted-foreground transition-colors hover:text-foreground" | 470 | className="flex w-full cursor-pointer items-center justify-center py-2 text-xs text-muted-foreground transition-colors hover:text-foreground" |
| 452 | > | 471 | > |
| 453 | Load older history | 472 | Load older history |
| @@ -455,7 +474,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line | |||
| 455 | ); | 474 | ); |
| 456 | } | 475 | } |
| 457 | return TIMELINE_LIST_HEADER; | 476 | return TIMELINE_LIST_HEADER; |
| 458 | }, [loadingOlder, hasMoreOlder, onLoadOlder]); | 477 | }, [loadingOlder, hasMoreOlder, requestOlderHistoryAtStart]); |
| 459 | 478 | ||
| 460 | const sharedState = useMemo<TimelineRowSharedState>( | 479 | const sharedState = useMemo<TimelineRowSharedState>( |
| 461 | () => ({ | 480 | () => ({ |
| @@ -205,7 +205,9 @@ export function useOlderThreadActivities( · 204 unmodified lines | |||
| 205 | // Latest merged set, read inside the async load handler so dedup runs | 205 | // Latest merged set, read inside the async load handler so dedup runs |
| 206 | // against current state, not the snapshot captured at dispatch time. | 206 | // against current state, not the snapshot captured at dispatch time. |
| 207 | const mergedActivitiesRef = useRef(mergedActivities); | 207 | const mergedActivitiesRef = useRef(mergedActivities); |
| 208 | mergedActivitiesRef.current = mergedActivities; | 208 | useLayoutEffect(() => { |
| 209 | mergedActivitiesRef.current = mergedActivities; | ||
| 210 | }, [mergedActivities]); | ||
| 209 | 211 | ||
| 210 | // Before any page is loaded the server flag is authoritative; afterwards | 212 | // Before any page is loaded the server flag is authoritative; afterwards |
| 211 | // the latest page's `hasMore` is. | 213 | // the latest page's `hasMore` is. |