History

fix(web): bound older-history auto-loading

Julius Marminge committed 3 months ago

Commit e94ab701 parent ef388d84 files · +124 −7Browse files

Unified · Split

4 files
  1. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +60 −0
  2. apps/web/src/components/chat/MessagesTimeline.logic.ts +36 −0
  3. apps/web/src/components/chat/MessagesTimeline.tsx +25 −6
  4. packages/client-runtime/src/state/olderThreadActivities.ts +3 −1
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+60−0
@@ -5,8 +5,68 @@ import { · 4 unmodified lines
55 deriveMessagesTimelineRows,
66 normalizeCompactToolLabel,
77 resolveAssistantMessageCopyState,
8+ resolveOlderHistoryAutoLoad,
89 } from "./MessagesTimeline.logic";
910
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+
1070 describe("computeMessageDurationStart", () => {
1171 it("returns message createdAt when there is no preceding user message", () => {
1272 const result = computeMessageDurationStart([
apps/web/src/components/chat/MessagesTimeline.logic.ts+36−0
@@ -21,6 +21,42 @@ export interface TimelineEndState { · 20 unmodified lines
2121 readonly isNearEnd?: boolean;
2222 }
2323
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+
2460 export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {
2561 return state?.isNearEnd ?? state?.isAtEnd;
2662 }
apps/web/src/components/chat/MessagesTimeline.tsx+25−6
@@ -73,6 +73,7 @@ import { · 72 unmodified lines
7373 deriveMessagesTimelineRows,
7474 normalizeCompactToolLabel,
7575 resolveAssistantMessageCopyState,
76+ resolveOlderHistoryAutoLoad,
7677 resolveTimelineIsAtEnd,
7778 resolveTimelineMinimapHasPersistentGutter,
7879 resolveTimelineMinimapHeightStyle,
@@ -337,6 +338,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 258 unmodified lines
337338 );
338339 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
339340 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]);
340349 const handleAnchorReady = useCallback(
341350 (info: { anchorIndex: number | undefined }) => {
342351 if (anchorMessageId !== null && info.anchorIndex !== undefined) {
@@ -368,10 +377,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 25 unmodified lines
368377 if (isAtEnd !== undefined) {
369378 onIsAtEndChange(isAtEnd);
370379 }
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();
375393 }
376394 if (!state || minimapItems.length === 0) {
377395 return;
@@ -403,6 +421,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 25 unmodified lines
403421 hasMoreOlder,
404422 loadingOlder,
405423 onLoadOlder,
424+ requestOlderHistoryAtStart,
406425 ]);
407426
408427 useEffect(() => {
@@ -447,7 +466,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 38 unmodified lines
447466 return (
448467 <button
449468 type="button"
450− onClick={onLoadOlder}
469+ onClick={requestOlderHistoryAtStart}
451470 className="flex w-full cursor-pointer items-center justify-center py-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
452471 >
453472 Load older history
@@ -455,7 +474,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
455474 );
456475 }
457476 return TIMELINE_LIST_HEADER;
458− }, [loadingOlder, hasMoreOlder, onLoadOlder]);
477+ }, [loadingOlder, hasMoreOlder, requestOlderHistoryAtStart]);
459478
460479 const sharedState = useMemo<TimelineRowSharedState>(
461480 () => ({
packages/client-runtime/src/state/olderThreadActivities.ts+3−1
@@ -205,7 +205,9 @@ export function useOlderThreadActivities( · 204 unmodified lines
205205 // Latest merged set, read inside the async load handler so dedup runs
206206 // against current state, not the snapshot captured at dispatch time.
207207 const mergedActivitiesRef = useRef(mergedActivities);
208− mergedActivitiesRef.current = mergedActivities;
208+ useLayoutEffect(() => {
209+ mergedActivitiesRef.current = mergedActivities;
210+ }, [mergedActivities]);
209211
210212 // Before any page is loaded the server flag is authoritative; afterwards
211213 // the latest page's `hasMore` is.