Unified · Split
| @@ -5,8 +5,68 @@ import { · 4 unmodified lines | ||
| 5 | 5 | deriveMessagesTimelineRows, |
| 6 | 6 | normalizeCompactToolLabel, |
| 7 | 7 | resolveAssistantMessageCopyState, |
| 8 | + resolveOlderHistoryAutoLoad, | |
| 8 | 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 | 70 | describe("computeMessageDurationStart", () => { |
| 11 | 71 | it("returns message createdAt when there is no preceding user message", () => { |
| 12 | 72 | const result = computeMessageDurationStart([ |
| @@ -21,6 +21,42 @@ export interface TimelineEndState { · 20 unmodified lines | ||
| 21 | 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 | 60 | export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { |
| 25 | 61 | return state?.isNearEnd ?? state?.isAtEnd; |
| 26 | 62 | } |
| @@ -73,6 +73,7 @@ import { · 72 unmodified lines | ||
| 73 | 73 | deriveMessagesTimelineRows, |
| 74 | 74 | normalizeCompactToolLabel, |
| 75 | 75 | resolveAssistantMessageCopyState, |
| 76 | + resolveOlderHistoryAutoLoad, | |
| 76 | 77 | resolveTimelineIsAtEnd, |
| 77 | 78 | resolveTimelineMinimapHasPersistentGutter, |
| 78 | 79 | resolveTimelineMinimapHeightStyle, |
| @@ -337,6 +338,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 258 unmodified lines | ||
| 337 | 338 | ); |
| 338 | 339 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); |
| 339 | 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 | 349 | const handleAnchorReady = useCallback( |
| 341 | 350 | (info: { anchorIndex: number | undefined }) => { |
| 342 | 351 | if (anchorMessageId !== null && info.anchorIndex !== undefined) { |
| @@ -368,10 +377,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 25 unmodified lines | ||
| 368 | 377 | if (isAtEnd !== undefined) { |
| 369 | 378 | onIsAtEndChange(isAtEnd); |
| 370 | 379 | } |
| 371 | − // Reaching the top lazy-loads older history; maintainVisibleContentPosition | |
| 372 | − // (set on the list) keeps the viewport anchored when rows prepend. | |
| 373 | − if (state?.isAtStart && hasMoreOlder && !loadingOlder) { | |
| 374 | − onLoadOlder?.(); | |
| 380 | + // Reaching the top lazy-loads one older page. Measurements rerun when the | |
| 381 | + // request settles and when rows prepend, so keep the visit latched until | |
| 382 | + // the user leaves the top; otherwise failures retry forever and successful | |
| 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 | 394 | if (!state || minimapItems.length === 0) { |
| 377 | 395 | return; |
| @@ -403,6 +421,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 25 unmodified lines | ||
| 403 | 421 | hasMoreOlder, |
| 404 | 422 | loadingOlder, |
| 405 | 423 | onLoadOlder, |
| 424 | + requestOlderHistoryAtStart, | |
| 406 | 425 | ]); |
| 407 | 426 | |
| 408 | 427 | useEffect(() => { |
| @@ -447,7 +466,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 38 unmodified lines | ||
| 447 | 466 | return ( |
| 448 | 467 | <button |
| 449 | 468 | type="button" |
| 450 | − onClick={onLoadOlder} | |
| 469 | + onClick={requestOlderHistoryAtStart} | |
| 451 | 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 | 472 | Load older history |
| @@ -455,7 +474,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line | ||
| 455 | 474 | ); |
| 456 | 475 | } |
| 457 | 476 | return TIMELINE_LIST_HEADER; |
| 458 | − }, [loadingOlder, hasMoreOlder, onLoadOlder]); | |
| 477 | + }, [loadingOlder, hasMoreOlder, requestOlderHistoryAtStart]); | |
| 459 | 478 | |
| 460 | 479 | const sharedState = useMemo<TimelineRowSharedState>( |
| 461 | 480 | () => ({ |
| @@ -205,7 +205,9 @@ export function useOlderThreadActivities( · 204 unmodified lines | ||
| 205 | 205 | // Latest merged set, read inside the async load handler so dedup runs |
| 206 | 206 | // against current state, not the snapshot captured at dispatch time. |
| 207 | 207 | const mergedActivitiesRef = useRef(mergedActivities); |
| 208 | − mergedActivitiesRef.current = mergedActivities; | |
| 208 | + useLayoutEffect(() => { | |
| 209 | + mergedActivitiesRef.current = mergedActivities; | |
| 210 | + }, [mergedActivities]); | |
| 209 | 211 | |
| 210 | 212 | // Before any page is loaded the server flag is authoritative; afterwards |
| 211 | 213 | // the latest page's `hasMore` is. |