@opengeni/react 2.3.0-canary.0 → 2.5.0-canary.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/{chunk-LS4POUTY.js → chunk-6H6S5V4Z.js} +250 -120
- package/dist/chunk-6H6S5V4Z.js.map +1 -0
- package/dist/{chunk-SE4W4WQ3.js → chunk-ATQJEWCA.js} +154 -114
- package/dist/chunk-ATQJEWCA.js.map +1 -0
- package/dist/{chunk-LTCHJ4MS.js → chunk-KSDXER4X.js} +21 -7
- package/dist/chunk-KSDXER4X.js.map +1 -0
- package/dist/{chunk-RSXJN73N.js → chunk-MXJGO7LE.js} +34 -1
- package/dist/chunk-MXJGO7LE.js.map +1 -0
- package/dist/{chunk-JOAVA2TW.js → chunk-T7VAYG2I.js} +8 -7
- package/dist/{chunk-JOAVA2TW.js.map → chunk-T7VAYG2I.js.map} +1 -1
- package/dist/components/composer.d.ts +1 -1
- package/dist/components/fleet-tile.d.ts +1 -1
- package/dist/components/message-timeline.d.ts +7 -2
- package/dist/composer.js +2 -2
- package/dist/hooks/use-session-events.d.ts +2 -1
- package/dist/hooks/use-session-lineage.d.ts +4 -0
- package/dist/hooks/use-session.d.ts +6 -0
- package/dist/hooks/use-workspace-sessions.d.ts +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +41 -19
- package/dist/index.js.map +1 -1
- package/dist/older-history.d.ts +29 -0
- package/dist/session-ui.d.ts +2 -0
- package/dist/session-ui.js +5 -2
- package/dist/session.d.ts +2 -0
- package/dist/session.js +5 -3
- package/package.json +2 -2
- package/src/components/composer.tsx +19 -5
- package/src/components/fleet-tile.tsx +7 -5
- package/src/components/message-timeline.tsx +399 -160
- package/src/components/tip-follow.ts +18 -7
- package/src/hooks/use-composer.ts +20 -6
- package/src/hooks/use-session-events.ts +84 -76
- package/src/hooks/use-session-lineage.ts +26 -8
- package/src/hooks/use-session.ts +35 -8
- package/src/hooks/use-workspace-sessions.ts +38 -13
- package/src/index.ts +2 -0
- package/src/older-history.ts +69 -0
- package/src/session-ui.ts +2 -0
- package/src/session.ts +2 -0
- package/src/timeline/activity-rail.tsx +1 -0
- package/dist/chunk-LS4POUTY.js.map +0 -1
- package/dist/chunk-LTCHJ4MS.js.map +0 -1
- package/dist/chunk-RSXJN73N.js.map +0 -1
- package/dist/chunk-SE4W4WQ3.js.map +0 -1
|
@@ -7,7 +7,6 @@ import type {
|
|
|
7
7
|
import {
|
|
8
8
|
ArrowDownIcon,
|
|
9
9
|
ArrowRightIcon,
|
|
10
|
-
ArrowUpToLineIcon,
|
|
11
10
|
BotIcon,
|
|
12
11
|
CheckCircle2Icon,
|
|
13
12
|
CheckIcon,
|
|
@@ -43,6 +42,11 @@ import {
|
|
|
43
42
|
import { cn } from "../lib/cn";
|
|
44
43
|
import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
|
|
45
44
|
import { prefersReducedMotion } from "../lib/motion";
|
|
45
|
+
import {
|
|
46
|
+
invokeOlderHistoryLoaderWithReceiptCapture,
|
|
47
|
+
type OlderHistoryLoadReceipt,
|
|
48
|
+
type OlderHistoryLoader,
|
|
49
|
+
} from "../older-history";
|
|
46
50
|
import { Markdown } from "./markdown";
|
|
47
51
|
import {
|
|
48
52
|
UserMessageBody,
|
|
@@ -181,8 +185,12 @@ export type MessageTimelineProps = {
|
|
|
181
185
|
hasOlder?: boolean | undefined;
|
|
182
186
|
/** An older window is being fetched; shows the quiet top shimmer. */
|
|
183
187
|
loadingOlder?: boolean | undefined;
|
|
184
|
-
/**
|
|
185
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Called when older history should backfill. Existing void, synchronous-value,
|
|
190
|
+
* and arbitrary-promise callbacks remain supported. Receipt-aware loaders
|
|
191
|
+
* preserve committed-page direction through wrappers and bounded windows.
|
|
192
|
+
*/
|
|
193
|
+
onLoadOlder?: OlderHistoryLoader | undefined;
|
|
186
194
|
/** Jump to the durable session start (bounded oldest window, no middle). */
|
|
187
195
|
onJumpToStart?: (() => void | Promise<void>) | undefined;
|
|
188
196
|
/** True while the oldest window is loading. */
|
|
@@ -213,8 +221,9 @@ export type MessageTimelineProps = {
|
|
|
213
221
|
*
|
|
214
222
|
* Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
|
|
215
223
|
* - Load/remount: hidden until tip is hard-snapped across a short settle; then
|
|
216
|
-
* reveal. Live tip: DOM
|
|
217
|
-
*
|
|
224
|
+
* reveal. Live tip: DOM growth advances the pinned viewport by the same
|
|
225
|
+
* amount, so rendered content is visible immediately. Only debt that already
|
|
226
|
+
* existed before the growth goes through the camera ease.
|
|
218
227
|
* - One continuous follow while hot (faster τ when behind); sleeps when cold.
|
|
219
228
|
* - While pinned, tip-debt from growth/collapse must NEVER unpin — only
|
|
220
229
|
* wheel/keys/pointer-armed scroll-up, or a settled scrollend away from the
|
|
@@ -235,6 +244,63 @@ const PIN_THRESHOLD_PX = 48;
|
|
|
235
244
|
*/
|
|
236
245
|
const OLDER_PREFETCH_MARGIN_PX = 400;
|
|
237
246
|
const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
|
|
247
|
+
const PRIMARY_ACTION_CLASS =
|
|
248
|
+
"inline-flex w-full shrink-0 items-center justify-center gap-1.5 rounded-og-md bg-og-accent px-3 py-1.5 text-og-menu font-medium text-og-accent-fg sm:w-auto";
|
|
249
|
+
const MESSAGE_BUBBLE_CLASS =
|
|
250
|
+
"w-fit max-w-full min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-og-md leading-6 text-og-fg";
|
|
251
|
+
const WAITING_PILL_CLASS =
|
|
252
|
+
"border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting";
|
|
253
|
+
const LOADING_CHIP_CLASS =
|
|
254
|
+
"pointer-events-none inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-og-control font-medium shadow-og-md backdrop-blur";
|
|
255
|
+
|
|
256
|
+
// State: 0 underfill retry, 1 prefetch pending, 2 compatibility-settled,
|
|
257
|
+
// 3 explicit no-progress waiting to become an underfill retry.
|
|
258
|
+
// The tuple identity is the exact request ownership fence. The boundary may rebase
|
|
259
|
+
// forward while that request is pending when a bounded live-tail append evicts
|
|
260
|
+
// its former oldest row. First-party loaders mark the exact owner when their
|
|
261
|
+
// older page commits; retained direction is the fallback for other hosts.
|
|
262
|
+
type OlderLoadAttempt = [
|
|
263
|
+
boundary: string | undefined,
|
|
264
|
+
state?: number,
|
|
265
|
+
receipt?: OlderHistoryLoadReceipt,
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
function invokeOlderLoad(
|
|
269
|
+
load: OlderHistoryLoader,
|
|
270
|
+
noProgress: () => void,
|
|
271
|
+
attempt: OlderLoadAttempt,
|
|
272
|
+
): 1 | undefined {
|
|
273
|
+
try {
|
|
274
|
+
// Receipt creation is captured synchronously through legacy wrappers such
|
|
275
|
+
// as `() => void loadOlder()`, even when the wrapper discards the return.
|
|
276
|
+
const result = invokeOlderHistoryLoaderWithReceiptCapture(load, (receipt) => {
|
|
277
|
+
attempt[2] = receipt;
|
|
278
|
+
}) as OlderHistoryLoadReceipt | PromiseLike<unknown> | unknown;
|
|
279
|
+
const receipt =
|
|
280
|
+
attempt[2] ??
|
|
281
|
+
(typeof (result as { committed?: unknown } | undefined)?.committed === "boolean"
|
|
282
|
+
? (result as OlderHistoryLoadReceipt)
|
|
283
|
+
: undefined);
|
|
284
|
+
if (receipt) {
|
|
285
|
+
attempt[2] = receipt;
|
|
286
|
+
void receipt.then(
|
|
287
|
+
(value) => value === false && !receipt.committed && noProgress(),
|
|
288
|
+
noProgress,
|
|
289
|
+
);
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
if (typeof (result as PromiseLike<unknown> | undefined)?.then != "function") {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
void (result as PromiseLike<unknown>).then(
|
|
296
|
+
(value) => value === false && noProgress(),
|
|
297
|
+
noProgress,
|
|
298
|
+
);
|
|
299
|
+
} catch {
|
|
300
|
+
noProgress();
|
|
301
|
+
}
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
238
304
|
|
|
239
305
|
/**
|
|
240
306
|
* Pinned = the viewport bottom is within PIN_THRESHOLD_PX of the content
|
|
@@ -245,7 +311,8 @@ const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
|
|
|
245
311
|
* cannot scroll at all is always pinned.
|
|
246
312
|
*/
|
|
247
313
|
function maxScrollOf(node: HTMLElement): number {
|
|
248
|
-
|
|
314
|
+
// Browser layout guarantees scrollHeight is at least clientHeight.
|
|
315
|
+
return node.scrollHeight - node.clientHeight;
|
|
249
316
|
}
|
|
250
317
|
|
|
251
318
|
/**
|
|
@@ -334,12 +401,16 @@ export function MessageTimeline({
|
|
|
334
401
|
className,
|
|
335
402
|
}: MessageTimelineProps) {
|
|
336
403
|
const resolvedItems = useMemo(() => {
|
|
337
|
-
const
|
|
404
|
+
const projectedItems = items ?? buildTimeline(events ?? []);
|
|
338
405
|
if (!shouldRenderAuthNeeded) {
|
|
339
|
-
return
|
|
406
|
+
return projectedItems;
|
|
340
407
|
}
|
|
341
|
-
return
|
|
408
|
+
return projectedItems.filter(
|
|
409
|
+
(item) => item.kind !== "auth-needed" || shouldRenderAuthNeeded(item),
|
|
410
|
+
);
|
|
342
411
|
}, [items, events, shouldRenderAuthNeeded]);
|
|
412
|
+
const sourceItems = items || events;
|
|
413
|
+
const olderBoundaryKey = sourceItems?.[0]?.id;
|
|
343
414
|
const allGroups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
|
|
344
415
|
const annotationSources = useMemo(() => {
|
|
345
416
|
const sources = new Map<string, TimelineAnnotationSourceDescriptor>();
|
|
@@ -367,15 +438,18 @@ export function MessageTimeline({
|
|
|
367
438
|
// history forever while the reader sits at the tip. Arm on first scroll-up.
|
|
368
439
|
const olderPrefetchArmedRef = useRef(false);
|
|
369
440
|
const [olderPrefetchArmed, setOlderPrefetchArmed] = useState(false);
|
|
370
|
-
// One loadOlder per visit to the top band. Re-arm only after the reader
|
|
371
|
-
// leaves that band (scrolls down / sentinel exits) — never from scrolling
|
|
372
|
-
// further toward y=0 (that was the batch-top load loop).
|
|
373
|
-
const olderLoadGateRef = useRef<"armed" | "cooling">("armed");
|
|
374
441
|
// A collapsed history tail can be shorter than the viewport. In that state
|
|
375
442
|
// there is no upward scroll range, so reader intent can never arm the top
|
|
376
443
|
// sentinel. Request one older page per loaded window until history either
|
|
377
|
-
// fills the viewport or the host reports that no older rows remain.
|
|
378
|
-
|
|
444
|
+
// fills the viewport or the host reports that no older rows remain. Each
|
|
445
|
+
// attempt owns the oldest loaded item boundary across BOTH the automatic
|
|
446
|
+
// underfill and reader-driven sentinel paths. Live-tail appends do not
|
|
447
|
+
// advance older pagination or release that exact owner.
|
|
448
|
+
const olderLoadAttemptRef = useRef<OlderLoadAttempt | null>(null);
|
|
449
|
+
const [underfillSettledAttempt, setUnderfillSettledAttempt] = useState<OlderLoadAttempt | null>(
|
|
450
|
+
null,
|
|
451
|
+
);
|
|
452
|
+
const underfillRetryReadyRef = useRef(false);
|
|
379
453
|
const resizeFollowRafRef = useRef<number | null>(null);
|
|
380
454
|
const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
|
|
381
455
|
// Content stays invisible until the tip is hard-parked across a short
|
|
@@ -449,11 +523,21 @@ export function MessageTimeline({
|
|
|
449
523
|
const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
|
|
450
524
|
const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
|
|
451
525
|
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
|
452
|
-
const
|
|
526
|
+
const firstItemGroupKeyRef = useRef<string | null>(null);
|
|
453
527
|
const groupOffsetByKeyRef = useRef<Map<string, number>>(new Map());
|
|
528
|
+
const firstItemContentTopRef = useRef<number | null>(null);
|
|
454
529
|
const firstItemId = resolvedItems[0]?.id ?? null;
|
|
455
|
-
|
|
456
|
-
|
|
530
|
+
// Pagination ownership follows the oldest committed input, before host
|
|
531
|
+
// filtering and grouping. A page containing only suppressed auth notices
|
|
532
|
+
// still advances this receipt, while live-tail appends leave it unchanged.
|
|
533
|
+
// Promise fulfillment alone does neither, so delayed prepends stay fenced.
|
|
534
|
+
const underfillRetryReady = (underfillRetryReadyRef.current = !!(
|
|
535
|
+
underfillSettledAttempt &&
|
|
536
|
+
underfillSettledAttempt === olderLoadAttemptRef.current &&
|
|
537
|
+
hasOlder &&
|
|
538
|
+
onLoadOlder &&
|
|
539
|
+
!loadingOlder
|
|
540
|
+
));
|
|
457
541
|
// Bulk paints (the initial tail window, a prepended older window — detected
|
|
458
542
|
// by the first group key changing) must not run per-row entrance animations.
|
|
459
543
|
const firstKeyChangedForBulk =
|
|
@@ -471,6 +555,15 @@ export function MessageTimeline({
|
|
|
471
555
|
}
|
|
472
556
|
}, []);
|
|
473
557
|
|
|
558
|
+
const rearmOlderPrefetchAfterLeavingTop = useCallback((node: HTMLElement) => {
|
|
559
|
+
if (node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
560
|
+
const state = olderLoadAttemptRef.current?.[1];
|
|
561
|
+
if (state === 2 || state === 3) {
|
|
562
|
+
olderLoadAttemptRef.current = null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}, []);
|
|
566
|
+
|
|
474
567
|
const revealedRef = useRef(revealed);
|
|
475
568
|
revealedRef.current = revealed;
|
|
476
569
|
|
|
@@ -502,7 +595,7 @@ export function MessageTimeline({
|
|
|
502
595
|
}, []);
|
|
503
596
|
|
|
504
597
|
const cancelLeaveFallback = useCallback(() => {
|
|
505
|
-
if (leaveFallbackRafRef.current
|
|
598
|
+
if (leaveFallbackRafRef.current != null) {
|
|
506
599
|
cancelFrame(leaveFallbackRafRef.current);
|
|
507
600
|
leaveFallbackRafRef.current = null;
|
|
508
601
|
}
|
|
@@ -515,7 +608,7 @@ export function MessageTimeline({
|
|
|
515
608
|
|
|
516
609
|
const stopFollow = useCallback(() => {
|
|
517
610
|
followRef.current = tipFollowCancel(followRef.current);
|
|
518
|
-
if (followFrameRef.current
|
|
611
|
+
if (followFrameRef.current != null) {
|
|
519
612
|
cancelFrame(followFrameRef.current);
|
|
520
613
|
followFrameRef.current = null;
|
|
521
614
|
}
|
|
@@ -558,7 +651,7 @@ export function MessageTimeline({
|
|
|
558
651
|
if (programmaticScrollRef.current > 0) {
|
|
559
652
|
return;
|
|
560
653
|
}
|
|
561
|
-
if (followRef.current.running || followFrameRef.current
|
|
654
|
+
if (followRef.current.running || followFrameRef.current != null) {
|
|
562
655
|
return;
|
|
563
656
|
}
|
|
564
657
|
if (isNearBottom(node) || maxScrollOf(node) <= 1) {
|
|
@@ -566,11 +659,9 @@ export function MessageTimeline({
|
|
|
566
659
|
return;
|
|
567
660
|
}
|
|
568
661
|
releasePinFromReader(node);
|
|
569
|
-
|
|
570
|
-
olderLoadGateRef.current = "armed";
|
|
571
|
-
}
|
|
662
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
572
663
|
},
|
|
573
|
-
[autoFollow, clearPendingReaderLeave, releasePinFromReader],
|
|
664
|
+
[autoFollow, clearPendingReaderLeave, rearmOlderPrefetchAfterLeavingTop, releasePinFromReader],
|
|
574
665
|
);
|
|
575
666
|
|
|
576
667
|
const scheduleLeaveFallback = useCallback(() => {
|
|
@@ -620,7 +711,7 @@ export function MessageTimeline({
|
|
|
620
711
|
target: EventTarget | null;
|
|
621
712
|
}) => {
|
|
622
713
|
// Primary button / touch / pen only. Ignore right-click etc.
|
|
623
|
-
if (event.button
|
|
714
|
+
if (event.button && event.pointerType === "mouse") {
|
|
624
715
|
return;
|
|
625
716
|
}
|
|
626
717
|
// Clicks on chips/buttons/links must not arm — their settle collapse
|
|
@@ -730,39 +821,61 @@ export function MessageTimeline({
|
|
|
730
821
|
);
|
|
731
822
|
|
|
732
823
|
const requestOlderIfUnderfilled = useCallback(
|
|
733
|
-
(node: HTMLElement) => {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
824
|
+
(node: HTMLElement, retry?: OlderLoadAttempt) => {
|
|
825
|
+
let currentAttempt = olderLoadAttemptRef.current;
|
|
826
|
+
if (retry && (currentAttempt !== retry || retry[0] !== olderBoundaryKey)) {
|
|
827
|
+
// AnimatePresence may retain the exiting button briefly. Its stale
|
|
828
|
+
// handler cannot replace a newer exact owner.
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const underfilled = maxScrollOf(node) <= 1;
|
|
832
|
+
if (!retry && underfilled && currentAttempt?.[1] === 3) {
|
|
833
|
+
// A receipted prefetch already declined or failed while the viewport
|
|
834
|
+
// was still scrollable. Collapse may later remove the scroll range;
|
|
835
|
+
// promote that exact owner to Retry without issuing another request.
|
|
836
|
+
currentAttempt[1] = 0;
|
|
837
|
+
setUnderfillSettledAttempt(currentAttempt);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (!retry && underfilled && currentAttempt?.[1] === 2) {
|
|
841
|
+
// A settled ordinary prefetch owns only this visit to the top band.
|
|
842
|
+
// If its resulting window cannot scroll, yield to automatic underfill
|
|
843
|
+
// so the reader is not stranded behind a cooldown they cannot exit.
|
|
844
|
+
currentAttempt = olderLoadAttemptRef.current = null;
|
|
741
845
|
}
|
|
742
846
|
// clientHeight=0 is pre-layout/headless, not evidence that the rendered
|
|
743
847
|
// history underfills a real viewport.
|
|
744
848
|
if (
|
|
745
849
|
node.clientHeight <= 1 ||
|
|
746
|
-
|
|
850
|
+
(!retry && !underfilled) ||
|
|
747
851
|
!hasOlder ||
|
|
748
852
|
loadingOlder ||
|
|
749
853
|
!onLoadOlder ||
|
|
750
|
-
|
|
854
|
+
(currentAttempt && !retry)
|
|
751
855
|
) {
|
|
752
856
|
return;
|
|
753
857
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
858
|
+
const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey]);
|
|
859
|
+
setUnderfillSettledAttempt(null);
|
|
860
|
+
// Every underfill request owns the ordinary sentinel too, even if reader
|
|
861
|
+
// intent had not armed it yet when this short-window request began.
|
|
862
|
+
const noProgress = () => {
|
|
863
|
+
if (scrollRef.current && olderLoadAttemptRef.current === attempt) {
|
|
864
|
+
setUnderfillSettledAttempt(attempt);
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
// Legacy fire-and-forget callbacks keep the one-shot behavior. Hosts
|
|
868
|
+
// that return the real promise opt into safe rejection/no-progress retry;
|
|
869
|
+
// exact `false` is the first-party request-not-accepted receipt.
|
|
870
|
+
// All other fulfillment retains this exact owner until its prepend
|
|
871
|
+
// boundary commits; promise settlement alone cannot prove progress.
|
|
872
|
+
invokeOlderLoad(onLoadOlder, noProgress, attempt);
|
|
761
873
|
},
|
|
762
|
-
[hasOlder,
|
|
874
|
+
[hasOlder, loadingOlder, olderBoundaryKey, onLoadOlder],
|
|
875
|
+
);
|
|
876
|
+
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
|
|
877
|
+
requestOlderIfUnderfilled as (node: HTMLElement, now?: number) => void,
|
|
763
878
|
);
|
|
764
|
-
|
|
765
|
-
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
|
|
766
879
|
const driveFollow = useCallback(
|
|
767
880
|
(node: HTMLElement, nowMs?: number) => {
|
|
768
881
|
if (!pinnedRef.current || hasNewerRef.current) {
|
|
@@ -820,7 +933,7 @@ export function MessageTimeline({
|
|
|
820
933
|
// A direct glue write re-based the camera — drop any stale fraction.
|
|
821
934
|
cameraTop: null,
|
|
822
935
|
};
|
|
823
|
-
if (followFrameRef.current
|
|
936
|
+
if (followFrameRef.current == null) {
|
|
824
937
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
825
938
|
followFrameRef.current = null;
|
|
826
939
|
const current = scrollRef.current;
|
|
@@ -854,7 +967,7 @@ export function MessageTimeline({
|
|
|
854
967
|
syncScrollBaseline(node);
|
|
855
968
|
if (result.state.running) {
|
|
856
969
|
cancelLeaveFallback();
|
|
857
|
-
if (followFrameRef.current
|
|
970
|
+
if (followFrameRef.current == null) {
|
|
858
971
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
859
972
|
followFrameRef.current = null;
|
|
860
973
|
const current = scrollRef.current;
|
|
@@ -863,7 +976,7 @@ export function MessageTimeline({
|
|
|
863
976
|
}
|
|
864
977
|
});
|
|
865
978
|
}
|
|
866
|
-
} else if (followFrameRef.current
|
|
979
|
+
} else if (followFrameRef.current != null) {
|
|
867
980
|
cancelFrame(followFrameRef.current);
|
|
868
981
|
followFrameRef.current = null;
|
|
869
982
|
}
|
|
@@ -886,12 +999,65 @@ export function MessageTimeline({
|
|
|
886
999
|
return;
|
|
887
1000
|
}
|
|
888
1001
|
const previousFirstItemId = previousFirstItemIdRef.current;
|
|
889
|
-
const
|
|
1002
|
+
const previousFirstItemGroupKey = firstItemGroupKeyRef.current;
|
|
890
1003
|
const previousGroupOffsetByKey = groupOffsetByKeyRef.current;
|
|
1004
|
+
const previousItemContentTop = firstItemContentTopRef.current;
|
|
891
1005
|
const previousScrollTop = lastScrollTopRef.current;
|
|
892
|
-
const
|
|
1006
|
+
const previousMaxScroll = lastMaxScrollRef.current;
|
|
1007
|
+
const wasAtLiveTailBeforeCommit =
|
|
1008
|
+
previousMaxScroll <= 1 ||
|
|
1009
|
+
previousMaxScroll - previousScrollTop < Math.min(PIN_THRESHOLD_PX, previousMaxScroll);
|
|
1010
|
+
const firstItemChanged = !!previousFirstItemId && firstItemId !== previousFirstItemId;
|
|
893
1011
|
const prepended =
|
|
894
1012
|
firstItemChanged && resolvedItems.some((item) => item.id === previousFirstItemId);
|
|
1013
|
+
const attempt = olderLoadAttemptRef.current;
|
|
1014
|
+
const committedZeroOverlapOlderReplacement = !!(
|
|
1015
|
+
attempt?.[2]?.committed &&
|
|
1016
|
+
firstItemChanged &&
|
|
1017
|
+
!prepended
|
|
1018
|
+
);
|
|
1019
|
+
const restorePrependAnchor = () => {
|
|
1020
|
+
// Keep the reader on the same retained rows. Prefer the exact first-item
|
|
1021
|
+
// content coordinate (needed when a prepend merges inside one group),
|
|
1022
|
+
// then the retained group offset, then scrollHeight as a final fallback.
|
|
1023
|
+
// If native anchoring already applied the same shift, leave scrollTop.
|
|
1024
|
+
let delta: number | null = null;
|
|
1025
|
+
if (previousFirstItemId && previousItemContentTop != null) {
|
|
1026
|
+
const itemEl = node.querySelector(
|
|
1027
|
+
`[data-og-item="${cssEscapeAttribute(previousFirstItemId)}"]`,
|
|
1028
|
+
);
|
|
1029
|
+
if (itemEl instanceof HTMLElement) {
|
|
1030
|
+
const scrollerTop = node.getBoundingClientRect().top;
|
|
1031
|
+
const currentItemTop = itemEl.getBoundingClientRect().top - scrollerTop + node.scrollTop;
|
|
1032
|
+
delta = Math.round(currentItemTop - previousItemContentTop);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
const anchorKey = previousFirstItemGroupKey;
|
|
1036
|
+
const previousAnchorTop =
|
|
1037
|
+
anchorKey != null ? previousGroupOffsetByKey.get(anchorKey) : undefined;
|
|
1038
|
+
const anchorEl =
|
|
1039
|
+
anchorKey != null
|
|
1040
|
+
? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
|
|
1041
|
+
: null;
|
|
1042
|
+
if (delta == null && anchorEl instanceof HTMLElement && previousAnchorTop !== undefined) {
|
|
1043
|
+
const moved = Math.round(anchorEl.offsetTop - previousAnchorTop);
|
|
1044
|
+
if (moved) {
|
|
1045
|
+
delta = moved;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (delta == null) {
|
|
1049
|
+
const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
|
|
1050
|
+
if (heightDelta > 0) {
|
|
1051
|
+
delta = heightDelta;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (delta != null) {
|
|
1055
|
+
const expected = previousScrollTop + delta;
|
|
1056
|
+
if (Math.abs(node.scrollTop - expected) > 2) {
|
|
1057
|
+
writeScrollTop(node, expected);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
895
1061
|
if (pendingJumpToStartRef.current && firstItemChanged) {
|
|
896
1062
|
// The oldest window landed — jump against the NEW DOM, and skip the
|
|
897
1063
|
// prepend correction (it would shift the reader away from the top).
|
|
@@ -907,6 +1073,42 @@ export function MessageTimeline({
|
|
|
907
1073
|
applyPinned(true);
|
|
908
1074
|
snapToBottom(node);
|
|
909
1075
|
}
|
|
1076
|
+
} else if (committedZeroOverlapOlderReplacement) {
|
|
1077
|
+
// An oldest-directed bounded page can replace every previously mounted
|
|
1078
|
+
// row. Its receipt is the only causal proof that this is backward
|
|
1079
|
+
// progress rather than live-tail forward eviction. Anchor at the bottom
|
|
1080
|
+
// seam before recording this window's scroll baselines so an unpinned
|
|
1081
|
+
// reader stays adjacent to the history they were reading.
|
|
1082
|
+
clearPendingReaderLeave();
|
|
1083
|
+
stopFollow();
|
|
1084
|
+
writeScrollTop(node, maxScrollOf(node));
|
|
1085
|
+
if (attempt?.[1] === 1 && pinnedRef.current) {
|
|
1086
|
+
applyPinned(false);
|
|
1087
|
+
}
|
|
1088
|
+
} else if (prepended) {
|
|
1089
|
+
if (
|
|
1090
|
+
autoFollow &&
|
|
1091
|
+
pinnedRef.current &&
|
|
1092
|
+
!hasNewer &&
|
|
1093
|
+
!pendingReaderLeaveRef.current &&
|
|
1094
|
+
(wasAtLiveTailBeforeCommit || olderLoadAttemptRef.current)
|
|
1095
|
+
) {
|
|
1096
|
+
// A pending short-window load owns the prepend even when live growth
|
|
1097
|
+
// has left its still-pinned camera with raw geometry debt. Unrelated
|
|
1098
|
+
// prepends retain the prior-tip fallback so a stale pin after an
|
|
1099
|
+
// extension/programmatic history jump still restores its row anchor.
|
|
1100
|
+
clearPendingReaderLeave();
|
|
1101
|
+
snapToBottom(node);
|
|
1102
|
+
} else {
|
|
1103
|
+
restorePrependAnchor();
|
|
1104
|
+
if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
1105
|
+
// Geometry or a pending extension/programmatic leave proves the
|
|
1106
|
+
// reader was browsing history even if the pin ref has not settled.
|
|
1107
|
+
stopFollow();
|
|
1108
|
+
clearPendingReaderLeave();
|
|
1109
|
+
applyPinned(false);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
910
1112
|
} else if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
911
1113
|
// Load/remount (still hidden): hard-park. Live tip after reveal: ease.
|
|
912
1114
|
// Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
|
|
@@ -921,66 +1123,25 @@ export function MessageTimeline({
|
|
|
921
1123
|
} else {
|
|
922
1124
|
driveFollow(node);
|
|
923
1125
|
}
|
|
924
|
-
} else if (prepended) {
|
|
925
|
-
// Keep the reader on the same retained rows. Prefer the offsetTop delta
|
|
926
|
-
// of the group that still holds the previous first item — that stays
|
|
927
|
-
// correct when loadOlder also truncates the tip (height delta then lies).
|
|
928
|
-
// If native anchoring already applied the same shift, leave scrollTop.
|
|
929
|
-
const anchorKey =
|
|
930
|
-
previousFirstItemId !== null
|
|
931
|
-
? previousGroupKeyByItemId.get(previousFirstItemId)
|
|
932
|
-
: undefined;
|
|
933
|
-
const previousAnchorTop =
|
|
934
|
-
anchorKey !== undefined ? previousGroupOffsetByKey.get(anchorKey) : undefined;
|
|
935
|
-
const anchorEl =
|
|
936
|
-
anchorKey !== undefined
|
|
937
|
-
? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
|
|
938
|
-
: null;
|
|
939
|
-
let delta: number | null = null;
|
|
940
|
-
if (anchorEl instanceof HTMLElement && previousAnchorTop !== undefined) {
|
|
941
|
-
const moved = Math.round(anchorEl.offsetTop - previousAnchorTop);
|
|
942
|
-
if (moved !== 0) {
|
|
943
|
-
delta = moved;
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
if (delta === null) {
|
|
947
|
-
const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
|
|
948
|
-
if (heightDelta > 0) {
|
|
949
|
-
delta = heightDelta;
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
if (delta !== null) {
|
|
953
|
-
const expected = previousScrollTop + delta;
|
|
954
|
-
if (Math.abs(node.scrollTop - expected) > 2) {
|
|
955
|
-
writeScrollTop(node, expected);
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
1126
|
}
|
|
959
1127
|
// After a prepend, if restore left us below the top prefetch band,
|
|
960
1128
|
// re-arm so a later approach can load again. Still cooling while parked
|
|
961
1129
|
// inside the band (short pages) — that stops the y=0 load loop.
|
|
962
|
-
if (
|
|
963
|
-
|
|
964
|
-
!pinnedRef.current &&
|
|
965
|
-
olderLoadGateRef.current === "cooling" &&
|
|
966
|
-
node.scrollTop > OLDER_PREFETCH_MARGIN_PX
|
|
967
|
-
) {
|
|
968
|
-
olderLoadGateRef.current = "armed";
|
|
1130
|
+
if (prepended && !pinnedRef.current && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
1131
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
969
1132
|
}
|
|
970
1133
|
previousFirstItemIdRef.current = firstItemId;
|
|
971
1134
|
previousScrollHeightRef.current = node.scrollHeight;
|
|
972
1135
|
syncScrollBaseline(node);
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
groupKeyByItemIdRef.current = nextKeyByItemId;
|
|
983
|
-
const needOffsets = prepended || firstItemChanged || !pinnedRef.current || Boolean(hasNewer);
|
|
1136
|
+
// offsetTop queries are O(groups); skip while pinned at the live tip
|
|
1137
|
+
// (every stream token used to remeasure the whole timeline).
|
|
1138
|
+
firstItemGroupKeyRef.current = groups[0]?.key ?? null;
|
|
1139
|
+
const needOffsets =
|
|
1140
|
+
prepended ||
|
|
1141
|
+
firstItemChanged ||
|
|
1142
|
+
!pinnedRef.current ||
|
|
1143
|
+
hasNewer ||
|
|
1144
|
+
firstItemContentTopRef.current == null;
|
|
984
1145
|
if (needOffsets) {
|
|
985
1146
|
const nextOffsetByKey = new Map<string, number>();
|
|
986
1147
|
for (const { key } of groups) {
|
|
@@ -990,14 +1151,52 @@ export function MessageTimeline({
|
|
|
990
1151
|
}
|
|
991
1152
|
}
|
|
992
1153
|
groupOffsetByKeyRef.current = nextOffsetByKey;
|
|
1154
|
+
const firstItemEl = firstItemId
|
|
1155
|
+
? node.querySelector(`[data-og-item="${cssEscapeAttribute(firstItemId)}"]`)
|
|
1156
|
+
: null;
|
|
1157
|
+
firstItemContentTopRef.current =
|
|
1158
|
+
firstItemId && firstItemEl instanceof HTMLElement
|
|
1159
|
+
? firstItemEl.getBoundingClientRect().top -
|
|
1160
|
+
node.getBoundingClientRect().top +
|
|
1161
|
+
node.scrollTop
|
|
1162
|
+
: null;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Promise settlement is not itself permission to retry. A receipt-marked
|
|
1166
|
+
// accepted page retires its exact owner on this commit even when projection
|
|
1167
|
+
// is empty or merges into the same first item. Without that mark, retain
|
|
1168
|
+
// the compatibility fallback: a retained prior boundary proves prepend,
|
|
1169
|
+
// while a missing prior boundary is forward eviction and merely rebases.
|
|
1170
|
+
if (!attempt) {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (!attempt[2]?.committed && attempt[0] === olderBoundaryKey) {
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (
|
|
1177
|
+
!attempt[2]?.committed &&
|
|
1178
|
+
attempt[0] &&
|
|
1179
|
+
!sourceItems?.find((entry) => entry.id === attempt[0])
|
|
1180
|
+
) {
|
|
1181
|
+
attempt[0] = olderBoundaryKey;
|
|
1182
|
+
return;
|
|
993
1183
|
}
|
|
1184
|
+
if (!attempt[1] || !hasOlder) {
|
|
1185
|
+
setUnderfillSettledAttempt((olderLoadAttemptRef.current = null));
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
// Boundary progress retires the request owner. A late settlement from
|
|
1189
|
+
// that completed prefetch cannot mutate the new window's cooldown owner.
|
|
1190
|
+
olderLoadAttemptRef.current = [olderBoundaryKey, 2];
|
|
1191
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1192
|
+
requestOlderIfUnderfilled(node);
|
|
994
1193
|
});
|
|
995
1194
|
|
|
996
1195
|
// First paint / session remount: keep the scroller hidden, snap to tip for
|
|
997
1196
|
// two animation frames (late sync layout), then reveal. Does not change the
|
|
998
|
-
// tip-follow
|
|
1197
|
+
// live tip-follow law used once `revealed` is true.
|
|
999
1198
|
useLayoutEffect(() => {
|
|
1000
|
-
if (revealed || allGroups.length
|
|
1199
|
+
if (revealed || !allGroups.length) {
|
|
1001
1200
|
return;
|
|
1002
1201
|
}
|
|
1003
1202
|
let cancelled = false;
|
|
@@ -1025,7 +1224,7 @@ export function MessageTimeline({
|
|
|
1025
1224
|
return () => {
|
|
1026
1225
|
cancelled = true;
|
|
1027
1226
|
cancelFrame(frame1);
|
|
1028
|
-
if (frame2
|
|
1227
|
+
if (frame2) {
|
|
1029
1228
|
cancelFrame(frame2);
|
|
1030
1229
|
}
|
|
1031
1230
|
};
|
|
@@ -1044,7 +1243,6 @@ export function MessageTimeline({
|
|
|
1044
1243
|
olderPrefetchArmedRef.current = false;
|
|
1045
1244
|
setOlderPrefetchArmed(false);
|
|
1046
1245
|
}
|
|
1047
|
-
olderLoadGateRef.current = "armed";
|
|
1048
1246
|
wantPinRef.current = false;
|
|
1049
1247
|
pendingJumpToStartRef.current = false;
|
|
1050
1248
|
previousFirstItemIdRef.current = null;
|
|
@@ -1053,12 +1251,12 @@ export function MessageTimeline({
|
|
|
1053
1251
|
lastMaxScrollRef.current = 0;
|
|
1054
1252
|
lastScrollHeightRef.current = 0;
|
|
1055
1253
|
lastClientHeightRef.current = 0;
|
|
1056
|
-
|
|
1254
|
+
firstItemGroupKeyRef.current = null;
|
|
1057
1255
|
groupOffsetByKeyRef.current = new Map();
|
|
1256
|
+
firstItemContentTopRef.current = null;
|
|
1058
1257
|
foldMemoryRef.current.clear();
|
|
1059
1258
|
userMessageDisclosureMemoryRef.current.clear();
|
|
1060
1259
|
disclosureKeepsUnpinnedRef.current = false;
|
|
1061
|
-
underfillLoadWindowRef.current = null;
|
|
1062
1260
|
seenActivityIdsRef.current.clear();
|
|
1063
1261
|
applyPinned(true);
|
|
1064
1262
|
}, [allGroups.length, revealed, applyPinned]);
|
|
@@ -1107,21 +1305,60 @@ export function MessageTimeline({
|
|
|
1107
1305
|
(entries) => {
|
|
1108
1306
|
const intersecting = entries.some((entry) => entry.isIntersecting);
|
|
1109
1307
|
if (!intersecting) {
|
|
1110
|
-
// Left the top band
|
|
1111
|
-
|
|
1308
|
+
// Left the top band. A settled ordinary prefetch releases its exact
|
|
1309
|
+
// owner here; a still-pending request remains cooling so a quick
|
|
1310
|
+
// leave/re-enter cannot overlap it.
|
|
1311
|
+
const attempt = olderLoadAttemptRef.current;
|
|
1312
|
+
if (attempt?.[1] === 2 || attempt?.[1] === 3) {
|
|
1313
|
+
olderLoadAttemptRef.current = null;
|
|
1314
|
+
}
|
|
1112
1315
|
return;
|
|
1113
1316
|
}
|
|
1114
|
-
|
|
1317
|
+
// Both automatic underfill and ordinary prefetch use one boundary
|
|
1318
|
+
// owner. In particular, live growth cannot arm a second sentinel load
|
|
1319
|
+
// while the short-window request that preceded it is still pending.
|
|
1320
|
+
if (olderLoadAttemptRef.current) {
|
|
1115
1321
|
return;
|
|
1116
1322
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1323
|
+
const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey, 1]);
|
|
1324
|
+
const noProgress = () => {
|
|
1325
|
+
if (!scrollRef.current || olderLoadAttemptRef.current !== attempt) {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
attempt[1] = 3;
|
|
1329
|
+
if (root.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
1330
|
+
olderLoadAttemptRef.current = null;
|
|
1331
|
+
} else if (maxScrollOf(root) <= 1) {
|
|
1332
|
+
attempt[1] = 0;
|
|
1333
|
+
setUnderfillSettledAttempt(attempt);
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
// Preserve legacy fire-and-forget top-band retries: the callback has
|
|
1337
|
+
// synchronously returned, but this visit remains cooling until exit.
|
|
1338
|
+
// Only rejection/throw/exact `false` is a no-progress receipt. Other
|
|
1339
|
+
// fulfillment retains the pending owner until boundary commit;
|
|
1340
|
+
// otherwise a delayed prepend could overlap a same-boundary request.
|
|
1341
|
+
if (
|
|
1342
|
+
!invokeOlderLoad(onLoadOlder, noProgress, attempt) &&
|
|
1343
|
+
olderLoadAttemptRef.current === attempt
|
|
1344
|
+
) {
|
|
1345
|
+
olderLoadAttemptRef.current[1] = 2;
|
|
1346
|
+
requestOlderIfUnderfilled(root);
|
|
1347
|
+
}
|
|
1119
1348
|
},
|
|
1120
1349
|
{ root, rootMargin: OLDER_PREFETCH_ROOT_MARGIN },
|
|
1121
1350
|
);
|
|
1122
1351
|
observer.observe(target);
|
|
1123
1352
|
return () => observer.disconnect();
|
|
1124
|
-
}, [
|
|
1353
|
+
}, [
|
|
1354
|
+
firstGroupKey,
|
|
1355
|
+
hasOlder,
|
|
1356
|
+
loadingOlder,
|
|
1357
|
+
olderBoundaryKey,
|
|
1358
|
+
olderPrefetchArmed,
|
|
1359
|
+
onLoadOlder,
|
|
1360
|
+
requestOlderIfUnderfilled,
|
|
1361
|
+
]);
|
|
1125
1362
|
|
|
1126
1363
|
// History view: page forward when the reader nears the bottom of the current
|
|
1127
1364
|
// non-tip window. Does not pull the whole gap — one density-bounded page.
|
|
@@ -1161,7 +1398,7 @@ export function MessageTimeline({
|
|
|
1161
1398
|
return;
|
|
1162
1399
|
}
|
|
1163
1400
|
const observer = new ResizeObserver(() => {
|
|
1164
|
-
if (resizeFollowRafRef.current
|
|
1401
|
+
if (resizeFollowRafRef.current != null) {
|
|
1165
1402
|
return;
|
|
1166
1403
|
}
|
|
1167
1404
|
resizeFollowRafRef.current = requestFrame(() => {
|
|
@@ -1188,7 +1425,7 @@ export function MessageTimeline({
|
|
|
1188
1425
|
observer.observe(node);
|
|
1189
1426
|
return () => {
|
|
1190
1427
|
observer.disconnect();
|
|
1191
|
-
if (resizeFollowRafRef.current
|
|
1428
|
+
if (resizeFollowRafRef.current != null) {
|
|
1192
1429
|
cancelFrame(resizeFollowRafRef.current);
|
|
1193
1430
|
resizeFollowRafRef.current = null;
|
|
1194
1431
|
}
|
|
@@ -1289,12 +1526,11 @@ export function MessageTimeline({
|
|
|
1289
1526
|
if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
|
|
1290
1527
|
readerIntentArmRef.current = false;
|
|
1291
1528
|
releasePinFromReader(node);
|
|
1292
|
-
|
|
1293
|
-
olderLoadGateRef.current = "armed";
|
|
1294
|
-
}
|
|
1529
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1295
1530
|
return;
|
|
1296
1531
|
}
|
|
1297
|
-
// Tip grew under a still viewport (no reader-up):
|
|
1532
|
+
// Tip grew under a still viewport (no reader-up): track the new growth
|
|
1533
|
+
// immediately and ease only any debt that already existed.
|
|
1298
1534
|
// Reader/extension scroll-up in progress: do not yank — scrollend decides.
|
|
1299
1535
|
// Near-bottom with tipDebt≈0 stays the quiet path (do not broaden follow
|
|
1300
1536
|
// inside PIN_THRESHOLD — that fights small intentional scroll-ups).
|
|
@@ -1341,9 +1577,7 @@ export function MessageTimeline({
|
|
|
1341
1577
|
}
|
|
1342
1578
|
// Re-arm older prefetch only after leaving the top band (scroll down into
|
|
1343
1579
|
// content). Never re-arm/load from continued scroll toward y=0.
|
|
1344
|
-
|
|
1345
|
-
olderLoadGateRef.current = "armed";
|
|
1346
|
-
}
|
|
1580
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1347
1581
|
};
|
|
1348
1582
|
|
|
1349
1583
|
const onScrollEnd = () => {
|
|
@@ -1407,7 +1641,7 @@ export function MessageTimeline({
|
|
|
1407
1641
|
)}
|
|
1408
1642
|
>
|
|
1409
1643
|
<div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
1410
|
-
{groups.length
|
|
1644
|
+
{!groups.length
|
|
1411
1645
|
? (emptyState ?? (
|
|
1412
1646
|
<p className="py-10 text-center text-og-menu text-og-fg-subtle">
|
|
1413
1647
|
No activity yet.
|
|
@@ -1494,7 +1728,8 @@ export function MessageTimeline({
|
|
|
1494
1728
|
<AnimatePresence>
|
|
1495
1729
|
{loadingOlder ||
|
|
1496
1730
|
loadingOldest ||
|
|
1497
|
-
(hasOlder && onJumpToStart && olderPrefetchArmed)
|
|
1731
|
+
(hasOlder && onJumpToStart && olderPrefetchArmed) ||
|
|
1732
|
+
underfillRetryReady ? (
|
|
1498
1733
|
// Floating over the scroller (not a timeline row) so showing and
|
|
1499
1734
|
// hiding it never reflows history under the reader.
|
|
1500
1735
|
<motion.div
|
|
@@ -1504,26 +1739,38 @@ export function MessageTimeline({
|
|
|
1504
1739
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
1505
1740
|
data-og-loading-older=""
|
|
1506
1741
|
aria-live="polite"
|
|
1507
|
-
className="absolute inset-x-0 top-3 z-10 flex justify-center
|
|
1742
|
+
className="absolute inset-x-0 top-3 z-10 flex justify-center"
|
|
1508
1743
|
>
|
|
1509
1744
|
{loadingOlder || loadingOldest ? (
|
|
1510
|
-
<span className=
|
|
1745
|
+
<span className={LOADING_CHIP_CLASS}>
|
|
1511
1746
|
<span className="og-shimmer-text">
|
|
1512
1747
|
{loadingOldest ? "Jumping to start…" : "Loading earlier activity…"}
|
|
1513
1748
|
</span>
|
|
1514
1749
|
</span>
|
|
1515
1750
|
) : null}
|
|
1516
|
-
{hasOlder &&
|
|
1751
|
+
{hasOlder &&
|
|
1752
|
+
!loadingOlder &&
|
|
1753
|
+
!loadingOldest &&
|
|
1754
|
+
(underfillRetryReady || onJumpToStart) ? (
|
|
1517
1755
|
<button
|
|
1518
1756
|
type="button"
|
|
1519
|
-
data-og-
|
|
1520
|
-
|
|
1757
|
+
data-og-retry={underfillRetryReady || undefined}
|
|
1758
|
+
data-og-jump-to-start={!underfillRetryReady || undefined}
|
|
1521
1759
|
onClick={() => {
|
|
1760
|
+
const node = scrollRef.current;
|
|
1761
|
+
if (underfillRetryReady) {
|
|
1762
|
+
if (node && underfillRetryReadyRef.current) {
|
|
1763
|
+
// AnimatePresence retains this handler during
|
|
1764
|
+
// exit. Current authorization plus the exact
|
|
1765
|
+
// attempt check prevent stale dispatch.
|
|
1766
|
+
requestOlderIfUnderfilled(node, underfillSettledAttempt!);
|
|
1767
|
+
}
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1522
1770
|
applyPinned(false);
|
|
1523
1771
|
pendingJumpToStartRef.current = true;
|
|
1524
1772
|
const seq = ++jumpToStartSeqRef.current;
|
|
1525
|
-
|
|
1526
|
-
void Promise.resolve(onJumpToStart()).then(
|
|
1773
|
+
void Promise.resolve(onJumpToStart!()).then(
|
|
1527
1774
|
() => {
|
|
1528
1775
|
// The commit that swaps in the oldest window consumes
|
|
1529
1776
|
// the flag against the new DOM; this write covers the
|
|
@@ -1552,14 +1799,9 @@ export function MessageTimeline({
|
|
|
1552
1799
|
},
|
|
1553
1800
|
);
|
|
1554
1801
|
}}
|
|
1555
|
-
className=
|
|
1556
|
-
"inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
|
|
1557
|
-
"text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
|
|
1558
|
-
"hover:border-og-border-strong disabled:opacity-60",
|
|
1559
|
-
)}
|
|
1802
|
+
className="rounded-full border border-og-border px-3 py-1.5 text-og-control"
|
|
1560
1803
|
>
|
|
1561
|
-
|
|
1562
|
-
Jump to start
|
|
1804
|
+
{underfillRetryReady ? "Retry earlier activity" : "Jump to start"}
|
|
1563
1805
|
</button>
|
|
1564
1806
|
) : null}
|
|
1565
1807
|
</motion.div>
|
|
@@ -1576,7 +1818,7 @@ export function MessageTimeline({
|
|
|
1576
1818
|
aria-live="polite"
|
|
1577
1819
|
className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
|
|
1578
1820
|
>
|
|
1579
|
-
<span className=
|
|
1821
|
+
<span className={LOADING_CHIP_CLASS}>
|
|
1580
1822
|
<span className="og-shimmer-text">Loading later activity…</span>
|
|
1581
1823
|
</span>
|
|
1582
1824
|
</motion.div>
|
|
@@ -1862,8 +2104,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1862
2104
|
// turn that is the second layer — quiet nested chips under the outer turn
|
|
1863
2105
|
// summary when contiguous activity naturally clusters (≥2 only).
|
|
1864
2106
|
const activityShouldFold =
|
|
1865
|
-
group.kind === "activity" &&
|
|
1866
|
-
Boolean(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
|
|
2107
|
+
group.kind === "activity" && !!(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
|
|
1867
2108
|
const containsGeneratedImage = timelineGroupContainsGeneratedImage(group);
|
|
1868
2109
|
// Latch live→folded so a top-level shell that was already mounted open can
|
|
1869
2110
|
// start the settle beat without remounting bare rail → wrapper.
|
|
@@ -1884,7 +2125,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1884
2125
|
);
|
|
1885
2126
|
}
|
|
1886
2127
|
const settleFold =
|
|
1887
|
-
group.kind === "turn" ?
|
|
2128
|
+
group.kind === "turn" ? !!(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle;
|
|
1888
2129
|
switch (group.kind) {
|
|
1889
2130
|
case "activity":
|
|
1890
2131
|
if (insideTurn) {
|
|
@@ -2246,7 +2487,7 @@ export function trailingAgentTextAfterTurn(
|
|
|
2246
2487
|
if (next?.kind === "item" && next.item.kind === "agent-message") {
|
|
2247
2488
|
const turnIds = collectTurnIdsFromGroups(group.groups);
|
|
2248
2489
|
// Degenerate body with no turnIds: do not guess — safer than lifting wrong.
|
|
2249
|
-
if (turnIds.size
|
|
2490
|
+
if (!turnIds.size) {
|
|
2250
2491
|
return undefined;
|
|
2251
2492
|
}
|
|
2252
2493
|
if (!next.item.turnId || !turnIds.has(next.item.turnId)) {
|
|
@@ -2265,7 +2506,7 @@ function collectTurnCopyText(
|
|
|
2265
2506
|
const parts = [collectAgentMessageText(groups), trailingAgentText?.trim() ?? ""].filter(
|
|
2266
2507
|
(part) => part.length > 0,
|
|
2267
2508
|
);
|
|
2268
|
-
if (parts.length
|
|
2509
|
+
if (!parts.length) {
|
|
2269
2510
|
return undefined;
|
|
2270
2511
|
}
|
|
2271
2512
|
return parts.join("\n\n");
|
|
@@ -2350,11 +2591,11 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2350
2591
|
const trigger =
|
|
2351
2592
|
item.trigger && item.phase !== "started" ? COMPACTION_TRIGGER_LABEL[item.trigger] : null;
|
|
2352
2593
|
const before =
|
|
2353
|
-
item.estimatedTokensBefore
|
|
2594
|
+
item.estimatedTokensBefore != null
|
|
2354
2595
|
? Math.round(item.estimatedTokensBefore).toLocaleString("en-US")
|
|
2355
2596
|
: null;
|
|
2356
2597
|
const after =
|
|
2357
|
-
item.estimatedTokensAfter
|
|
2598
|
+
item.estimatedTokensAfter != null
|
|
2358
2599
|
? Math.round(item.estimatedTokensAfter).toLocaleString("en-US")
|
|
2359
2600
|
: null;
|
|
2360
2601
|
const title =
|
|
@@ -2375,7 +2616,7 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2375
2616
|
item.phase === "skipped" && item.skipReason === "summarization_failed"
|
|
2376
2617
|
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
2377
2618
|
: item.phase === "started"
|
|
2378
|
-
?
|
|
2619
|
+
? WAITING_PILL_CLASS
|
|
2379
2620
|
: "border-og-border bg-og-surface-1 text-og-fg-muted";
|
|
2380
2621
|
return (
|
|
2381
2622
|
<div className={cn(enter && "animate-og-enter", "flex justify-center")}>
|
|
@@ -2455,7 +2696,7 @@ function UserMessageRow({
|
|
|
2455
2696
|
className="w-fit max-w-full min-w-0"
|
|
2456
2697
|
trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
|
|
2457
2698
|
>
|
|
2458
|
-
<div className=
|
|
2699
|
+
<div className={MESSAGE_BUBBLE_CLASS}>
|
|
2459
2700
|
{item.text ? (
|
|
2460
2701
|
<div data-og-annotation-source-key={item.annotationSource?.eventId}>
|
|
2461
2702
|
{renderMessageText ? (
|
|
@@ -2577,7 +2818,7 @@ function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
|
|
|
2577
2818
|
className="w-fit max-w-[90%] min-w-0 sm:max-w-[82%]"
|
|
2578
2819
|
trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
|
|
2579
2820
|
>
|
|
2580
|
-
<div className=
|
|
2821
|
+
<div className={MESSAGE_BUBBLE_CLASS}>
|
|
2581
2822
|
<p className="text-og-xs font-medium text-og-fg-subtle">{settledLabel}</p>
|
|
2582
2823
|
{item.response.outcome === "answered" ? (
|
|
2583
2824
|
<div className="mt-1.5 space-y-3">
|
|
@@ -2747,9 +2988,7 @@ function WorkerCompletionRow({
|
|
|
2747
2988
|
// payloads can carry a leftover pausedReason/rationale from earlier in the
|
|
2748
2989
|
// worker's life, and a "Worker completed" card must not show a pause section.
|
|
2749
2990
|
const showPausedReason =
|
|
2750
|
-
item.childStatus !== "failed" &&
|
|
2751
|
-
item.goalStatus === "paused" &&
|
|
2752
|
-
Boolean(item.pausedReason?.trim());
|
|
2991
|
+
item.childStatus !== "failed" && item.goalStatus === "paused" && !!item.pausedReason?.trim();
|
|
2753
2992
|
const details: { label: string; value: string; muted?: boolean }[] = [
|
|
2754
2993
|
...(item.text.trim() ? [{ label: "Report", value: item.text.trim() }] : []),
|
|
2755
2994
|
...(item.evidence?.trim()
|
|
@@ -2888,14 +3127,14 @@ const GOAL_META: Record<GoalItem["action"], GoalMeta> = {
|
|
|
2888
3127
|
},
|
|
2889
3128
|
paused: {
|
|
2890
3129
|
label: "Goal paused",
|
|
2891
|
-
pill:
|
|
3130
|
+
pill: WAITING_PILL_CLASS,
|
|
2892
3131
|
icon: PauseIcon,
|
|
2893
3132
|
},
|
|
2894
3133
|
resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
|
|
2895
3134
|
cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
|
|
2896
3135
|
held: {
|
|
2897
3136
|
label: "Goal held",
|
|
2898
|
-
pill:
|
|
3137
|
+
pill: WAITING_PILL_CLASS,
|
|
2899
3138
|
icon: PauseCircleIcon,
|
|
2900
3139
|
},
|
|
2901
3140
|
continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
|
|
@@ -3093,7 +3332,7 @@ function NoticeRow({ item }: { item: NoticeItem }) {
|
|
|
3093
3332
|
item.tone === "failed"
|
|
3094
3333
|
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
3095
3334
|
: item.tone === "waiting"
|
|
3096
|
-
?
|
|
3335
|
+
? WAITING_PILL_CLASS
|
|
3097
3336
|
: "border-og-border bg-og-surface-1 text-og-fg-muted";
|
|
3098
3337
|
return (
|
|
3099
3338
|
<div
|
|
@@ -3226,7 +3465,7 @@ function AuthNeededRow({
|
|
|
3226
3465
|
onClick={() => void start()}
|
|
3227
3466
|
disabled={busy}
|
|
3228
3467
|
className={cn(
|
|
3229
|
-
|
|
3468
|
+
PRIMARY_ACTION_CLASS,
|
|
3230
3469
|
"transition-colors hover:bg-og-accent-strong disabled:opacity-70 pointer-coarse:min-h-9",
|
|
3231
3470
|
)}
|
|
3232
3471
|
>
|
|
@@ -3239,7 +3478,7 @@ function AuthNeededRow({
|
|
|
3239
3478
|
rel="noreferrer"
|
|
3240
3479
|
target="_blank"
|
|
3241
3480
|
className={cn(
|
|
3242
|
-
|
|
3481
|
+
PRIMARY_ACTION_CLASS,
|
|
3243
3482
|
"transition-colors hover:bg-og-accent-strong pointer-coarse:min-h-9",
|
|
3244
3483
|
)}
|
|
3245
3484
|
>
|
|
@@ -3302,7 +3541,7 @@ function AuthProviderLogo({ src, label }: { src: string | null; label: string })
|
|
|
3302
3541
|
`capabilityMonogram` so the reconnect tile reads like every other logo tile. */
|
|
3303
3542
|
function monogram(label: string): string {
|
|
3304
3543
|
const words = label.trim().split(/\s+/).filter(Boolean);
|
|
3305
|
-
if (words.length
|
|
3544
|
+
if (!words.length) {
|
|
3306
3545
|
return "?";
|
|
3307
3546
|
}
|
|
3308
3547
|
if (words.length === 1) {
|