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
5 deriveMessagesTimelineRows,5 deriveMessagesTimelineRows,
6 normalizeCompactToolLabel,6 normalizeCompactToolLabel,
7 resolveAssistantMessageCopyState,7 resolveAssistantMessageCopyState,
8 resolveOlderHistoryAutoLoad,
8} from "./MessagesTimeline.logic";9} from "./MessagesTimeline.logic";
9 10
11describe("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
10describe("computeMessageDurationStart", () => {70describe("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([
apps/web/src/components/chat/MessagesTimeline.logic.ts+36−0
@@ -21,6 +21,42 @@ export interface TimelineEndState { · 20 unmodified lines
21 readonly isNearEnd?: boolean;21 readonly isNearEnd?: boolean;
22}22}
23 23
24export interface OlderHistoryAutoLoadInput {
25 readonly isAtStart: boolean | undefined;
26 readonly hasMoreOlder: boolean;
27 readonly loadingOlder: boolean;
28 readonly requestedAtStart: boolean;
29}
30
31export 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 */
43export 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
24export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {60export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {
25 return state?.isNearEnd ?? state?.isAtEnd;61 return state?.isNearEnd ?? state?.isAtEnd;
26}62}
apps/web/src/components/chat/MessagesTimeline.tsx+25−6
@@ -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; maintainVisibleContentPosition380 // 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 <button467 <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 history472 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 () => ({
packages/client-runtime/src/state/olderThreadActivities.ts+3−1
@@ -205,7 +205,9 @@ export function useOlderThreadActivities( · 204 unmodified lines
205 // Latest merged set, read inside the async load handler so dedup runs205 // 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; afterwards212 // 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.