@opengeni/react 2.2.0-canary.0 → 2.5.0-canary.0
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-5RZN3DD5.js → chunk-6H6S5V4Z.js} +280 -113
- package/dist/chunk-6H6S5V4Z.js.map +1 -0
- package/dist/{chunk-UOLJWT5Y.js → chunk-ATQJEWCA.js} +154 -114
- package/dist/chunk-ATQJEWCA.js.map +1 -0
- package/dist/{chunk-XSA2QAKP.js → chunk-MXJGO7LE.js} +65 -3
- package/dist/chunk-MXJGO7LE.js.map +1 -0
- package/dist/{chunk-LTCHJ4MS.js → chunk-NVREJA6V.js} +2 -2
- 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/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/dist/timeline/types.d.ts +2 -0
- package/package.json +2 -2
- package/src/components/fleet-tile.tsx +7 -5
- package/src/components/human-input-form.tsx +14 -6
- package/src/components/message-timeline.tsx +428 -137
- 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/src/timeline/projection.ts +53 -2
- package/src/timeline/types.ts +2 -0
- package/dist/chunk-5RZN3DD5.js.map +0 -1
- package/dist/chunk-UOLJWT5Y.js.map +0 -1
- package/dist/chunk-XSA2QAKP.js.map +0 -1
- /package/dist/{chunk-LTCHJ4MS.js.map → chunk-NVREJA6V.js.map} +0 -0
|
@@ -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,10 +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
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
441
|
+
// A collapsed history tail can be shorter than the viewport. In that state
|
|
442
|
+
// there is no upward scroll range, so reader intent can never arm the top
|
|
443
|
+
// sentinel. Request one older page per loaded window until history either
|
|
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);
|
|
374
453
|
const resizeFollowRafRef = useRef<number | null>(null);
|
|
375
454
|
const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
|
|
376
455
|
// Content stays invisible until the tip is hard-parked across a short
|
|
@@ -444,9 +523,21 @@ export function MessageTimeline({
|
|
|
444
523
|
const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
|
|
445
524
|
const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
|
|
446
525
|
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
|
447
|
-
const
|
|
526
|
+
const firstItemGroupKeyRef = useRef<string | null>(null);
|
|
448
527
|
const groupOffsetByKeyRef = useRef<Map<string, number>>(new Map());
|
|
528
|
+
const firstItemContentTopRef = useRef<number | null>(null);
|
|
449
529
|
const firstItemId = resolvedItems[0]?.id ?? null;
|
|
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
|
+
));
|
|
450
541
|
// Bulk paints (the initial tail window, a prepended older window — detected
|
|
451
542
|
// by the first group key changing) must not run per-row entrance animations.
|
|
452
543
|
const firstKeyChangedForBulk =
|
|
@@ -464,6 +555,15 @@ export function MessageTimeline({
|
|
|
464
555
|
}
|
|
465
556
|
}, []);
|
|
466
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
|
+
|
|
467
567
|
const revealedRef = useRef(revealed);
|
|
468
568
|
revealedRef.current = revealed;
|
|
469
569
|
|
|
@@ -495,7 +595,7 @@ export function MessageTimeline({
|
|
|
495
595
|
}, []);
|
|
496
596
|
|
|
497
597
|
const cancelLeaveFallback = useCallback(() => {
|
|
498
|
-
if (leaveFallbackRafRef.current
|
|
598
|
+
if (leaveFallbackRafRef.current != null) {
|
|
499
599
|
cancelFrame(leaveFallbackRafRef.current);
|
|
500
600
|
leaveFallbackRafRef.current = null;
|
|
501
601
|
}
|
|
@@ -508,7 +608,7 @@ export function MessageTimeline({
|
|
|
508
608
|
|
|
509
609
|
const stopFollow = useCallback(() => {
|
|
510
610
|
followRef.current = tipFollowCancel(followRef.current);
|
|
511
|
-
if (followFrameRef.current
|
|
611
|
+
if (followFrameRef.current != null) {
|
|
512
612
|
cancelFrame(followFrameRef.current);
|
|
513
613
|
followFrameRef.current = null;
|
|
514
614
|
}
|
|
@@ -551,7 +651,7 @@ export function MessageTimeline({
|
|
|
551
651
|
if (programmaticScrollRef.current > 0) {
|
|
552
652
|
return;
|
|
553
653
|
}
|
|
554
|
-
if (followRef.current.running || followFrameRef.current
|
|
654
|
+
if (followRef.current.running || followFrameRef.current != null) {
|
|
555
655
|
return;
|
|
556
656
|
}
|
|
557
657
|
if (isNearBottom(node) || maxScrollOf(node) <= 1) {
|
|
@@ -559,11 +659,9 @@ export function MessageTimeline({
|
|
|
559
659
|
return;
|
|
560
660
|
}
|
|
561
661
|
releasePinFromReader(node);
|
|
562
|
-
|
|
563
|
-
olderLoadGateRef.current = "armed";
|
|
564
|
-
}
|
|
662
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
565
663
|
},
|
|
566
|
-
[autoFollow, clearPendingReaderLeave, releasePinFromReader],
|
|
664
|
+
[autoFollow, clearPendingReaderLeave, rearmOlderPrefetchAfterLeavingTop, releasePinFromReader],
|
|
567
665
|
);
|
|
568
666
|
|
|
569
667
|
const scheduleLeaveFallback = useCallback(() => {
|
|
@@ -613,7 +711,7 @@ export function MessageTimeline({
|
|
|
613
711
|
target: EventTarget | null;
|
|
614
712
|
}) => {
|
|
615
713
|
// Primary button / touch / pen only. Ignore right-click etc.
|
|
616
|
-
if (event.button
|
|
714
|
+
if (event.button && event.pointerType === "mouse") {
|
|
617
715
|
return;
|
|
618
716
|
}
|
|
619
717
|
// Clicks on chips/buttons/links must not arm — their settle collapse
|
|
@@ -722,7 +820,62 @@ export function MessageTimeline({
|
|
|
722
820
|
[beginUserMessageDisclosureChange],
|
|
723
821
|
);
|
|
724
822
|
|
|
725
|
-
const
|
|
823
|
+
const requestOlderIfUnderfilled = useCallback(
|
|
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;
|
|
845
|
+
}
|
|
846
|
+
// clientHeight=0 is pre-layout/headless, not evidence that the rendered
|
|
847
|
+
// history underfills a real viewport.
|
|
848
|
+
if (
|
|
849
|
+
node.clientHeight <= 1 ||
|
|
850
|
+
(!retry && !underfilled) ||
|
|
851
|
+
!hasOlder ||
|
|
852
|
+
loadingOlder ||
|
|
853
|
+
!onLoadOlder ||
|
|
854
|
+
(currentAttempt && !retry)
|
|
855
|
+
) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
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);
|
|
873
|
+
},
|
|
874
|
+
[hasOlder, loadingOlder, olderBoundaryKey, onLoadOlder],
|
|
875
|
+
);
|
|
876
|
+
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
|
|
877
|
+
requestOlderIfUnderfilled as (node: HTMLElement, now?: number) => void,
|
|
878
|
+
);
|
|
726
879
|
const driveFollow = useCallback(
|
|
727
880
|
(node: HTMLElement, nowMs?: number) => {
|
|
728
881
|
if (!pinnedRef.current || hasNewerRef.current) {
|
|
@@ -780,7 +933,7 @@ export function MessageTimeline({
|
|
|
780
933
|
// A direct glue write re-based the camera — drop any stale fraction.
|
|
781
934
|
cameraTop: null,
|
|
782
935
|
};
|
|
783
|
-
if (followFrameRef.current
|
|
936
|
+
if (followFrameRef.current == null) {
|
|
784
937
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
785
938
|
followFrameRef.current = null;
|
|
786
939
|
const current = scrollRef.current;
|
|
@@ -814,7 +967,7 @@ export function MessageTimeline({
|
|
|
814
967
|
syncScrollBaseline(node);
|
|
815
968
|
if (result.state.running) {
|
|
816
969
|
cancelLeaveFallback();
|
|
817
|
-
if (followFrameRef.current
|
|
970
|
+
if (followFrameRef.current == null) {
|
|
818
971
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
819
972
|
followFrameRef.current = null;
|
|
820
973
|
const current = scrollRef.current;
|
|
@@ -823,7 +976,7 @@ export function MessageTimeline({
|
|
|
823
976
|
}
|
|
824
977
|
});
|
|
825
978
|
}
|
|
826
|
-
} else if (followFrameRef.current
|
|
979
|
+
} else if (followFrameRef.current != null) {
|
|
827
980
|
cancelFrame(followFrameRef.current);
|
|
828
981
|
followFrameRef.current = null;
|
|
829
982
|
}
|
|
@@ -846,12 +999,65 @@ export function MessageTimeline({
|
|
|
846
999
|
return;
|
|
847
1000
|
}
|
|
848
1001
|
const previousFirstItemId = previousFirstItemIdRef.current;
|
|
849
|
-
const
|
|
1002
|
+
const previousFirstItemGroupKey = firstItemGroupKeyRef.current;
|
|
850
1003
|
const previousGroupOffsetByKey = groupOffsetByKeyRef.current;
|
|
1004
|
+
const previousItemContentTop = firstItemContentTopRef.current;
|
|
851
1005
|
const previousScrollTop = lastScrollTopRef.current;
|
|
852
|
-
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;
|
|
853
1011
|
const prepended =
|
|
854
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
|
+
};
|
|
855
1061
|
if (pendingJumpToStartRef.current && firstItemChanged) {
|
|
856
1062
|
// The oldest window landed — jump against the NEW DOM, and skip the
|
|
857
1063
|
// prepend correction (it would shift the reader away from the top).
|
|
@@ -867,6 +1073,42 @@ export function MessageTimeline({
|
|
|
867
1073
|
applyPinned(true);
|
|
868
1074
|
snapToBottom(node);
|
|
869
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
|
+
}
|
|
870
1112
|
} else if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
871
1113
|
// Load/remount (still hidden): hard-park. Live tip after reveal: ease.
|
|
872
1114
|
// Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
|
|
@@ -881,66 +1123,25 @@ export function MessageTimeline({
|
|
|
881
1123
|
} else {
|
|
882
1124
|
driveFollow(node);
|
|
883
1125
|
}
|
|
884
|
-
} else if (prepended) {
|
|
885
|
-
// Keep the reader on the same retained rows. Prefer the offsetTop delta
|
|
886
|
-
// of the group that still holds the previous first item — that stays
|
|
887
|
-
// correct when loadOlder also truncates the tip (height delta then lies).
|
|
888
|
-
// If native anchoring already applied the same shift, leave scrollTop.
|
|
889
|
-
const anchorKey =
|
|
890
|
-
previousFirstItemId !== null
|
|
891
|
-
? previousGroupKeyByItemId.get(previousFirstItemId)
|
|
892
|
-
: undefined;
|
|
893
|
-
const previousAnchorTop =
|
|
894
|
-
anchorKey !== undefined ? previousGroupOffsetByKey.get(anchorKey) : undefined;
|
|
895
|
-
const anchorEl =
|
|
896
|
-
anchorKey !== undefined
|
|
897
|
-
? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
|
|
898
|
-
: null;
|
|
899
|
-
let delta: number | null = null;
|
|
900
|
-
if (anchorEl instanceof HTMLElement && previousAnchorTop !== undefined) {
|
|
901
|
-
const moved = Math.round(anchorEl.offsetTop - previousAnchorTop);
|
|
902
|
-
if (moved !== 0) {
|
|
903
|
-
delta = moved;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
if (delta === null) {
|
|
907
|
-
const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
|
|
908
|
-
if (heightDelta > 0) {
|
|
909
|
-
delta = heightDelta;
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
if (delta !== null) {
|
|
913
|
-
const expected = previousScrollTop + delta;
|
|
914
|
-
if (Math.abs(node.scrollTop - expected) > 2) {
|
|
915
|
-
writeScrollTop(node, expected);
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
1126
|
}
|
|
919
1127
|
// After a prepend, if restore left us below the top prefetch band,
|
|
920
1128
|
// re-arm so a later approach can load again. Still cooling while parked
|
|
921
1129
|
// inside the band (short pages) — that stops the y=0 load loop.
|
|
922
|
-
if (
|
|
923
|
-
|
|
924
|
-
!pinnedRef.current &&
|
|
925
|
-
olderLoadGateRef.current === "cooling" &&
|
|
926
|
-
node.scrollTop > OLDER_PREFETCH_MARGIN_PX
|
|
927
|
-
) {
|
|
928
|
-
olderLoadGateRef.current = "armed";
|
|
1130
|
+
if (prepended && !pinnedRef.current && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
1131
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
929
1132
|
}
|
|
930
1133
|
previousFirstItemIdRef.current = firstItemId;
|
|
931
1134
|
previousScrollHeightRef.current = node.scrollHeight;
|
|
932
1135
|
syncScrollBaseline(node);
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
groupKeyByItemIdRef.current = nextKeyByItemId;
|
|
943
|
-
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;
|
|
944
1145
|
if (needOffsets) {
|
|
945
1146
|
const nextOffsetByKey = new Map<string, number>();
|
|
946
1147
|
for (const { key } of groups) {
|
|
@@ -950,14 +1151,52 @@ export function MessageTimeline({
|
|
|
950
1151
|
}
|
|
951
1152
|
}
|
|
952
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;
|
|
1183
|
+
}
|
|
1184
|
+
if (!attempt[1] || !hasOlder) {
|
|
1185
|
+
setUnderfillSettledAttempt((olderLoadAttemptRef.current = null));
|
|
1186
|
+
return;
|
|
953
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);
|
|
954
1193
|
});
|
|
955
1194
|
|
|
956
1195
|
// First paint / session remount: keep the scroller hidden, snap to tip for
|
|
957
1196
|
// two animation frames (late sync layout), then reveal. Does not change the
|
|
958
|
-
// tip-follow
|
|
1197
|
+
// live tip-follow law used once `revealed` is true.
|
|
959
1198
|
useLayoutEffect(() => {
|
|
960
|
-
if (revealed || allGroups.length
|
|
1199
|
+
if (revealed || !allGroups.length) {
|
|
961
1200
|
return;
|
|
962
1201
|
}
|
|
963
1202
|
let cancelled = false;
|
|
@@ -985,7 +1224,7 @@ export function MessageTimeline({
|
|
|
985
1224
|
return () => {
|
|
986
1225
|
cancelled = true;
|
|
987
1226
|
cancelFrame(frame1);
|
|
988
|
-
if (frame2
|
|
1227
|
+
if (frame2) {
|
|
989
1228
|
cancelFrame(frame2);
|
|
990
1229
|
}
|
|
991
1230
|
};
|
|
@@ -1004,7 +1243,6 @@ export function MessageTimeline({
|
|
|
1004
1243
|
olderPrefetchArmedRef.current = false;
|
|
1005
1244
|
setOlderPrefetchArmed(false);
|
|
1006
1245
|
}
|
|
1007
|
-
olderLoadGateRef.current = "armed";
|
|
1008
1246
|
wantPinRef.current = false;
|
|
1009
1247
|
pendingJumpToStartRef.current = false;
|
|
1010
1248
|
previousFirstItemIdRef.current = null;
|
|
@@ -1013,8 +1251,9 @@ export function MessageTimeline({
|
|
|
1013
1251
|
lastMaxScrollRef.current = 0;
|
|
1014
1252
|
lastScrollHeightRef.current = 0;
|
|
1015
1253
|
lastClientHeightRef.current = 0;
|
|
1016
|
-
|
|
1254
|
+
firstItemGroupKeyRef.current = null;
|
|
1017
1255
|
groupOffsetByKeyRef.current = new Map();
|
|
1256
|
+
firstItemContentTopRef.current = null;
|
|
1018
1257
|
foldMemoryRef.current.clear();
|
|
1019
1258
|
userMessageDisclosureMemoryRef.current.clear();
|
|
1020
1259
|
disclosureKeepsUnpinnedRef.current = false;
|
|
@@ -1022,6 +1261,16 @@ export function MessageTimeline({
|
|
|
1022
1261
|
applyPinned(true);
|
|
1023
1262
|
}, [allGroups.length, revealed, applyPinned]);
|
|
1024
1263
|
|
|
1264
|
+
// Parent commits cover the initial/history-loading cases. Disclosure state
|
|
1265
|
+
// changes are child-local, so the ResizeObserver below owns dynamic collapse
|
|
1266
|
+
// and expansion after mount.
|
|
1267
|
+
useEffect(() => {
|
|
1268
|
+
const node = scrollRef.current;
|
|
1269
|
+
if (node) {
|
|
1270
|
+
requestOlderIfUnderfilled(node);
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1025
1274
|
// Clear the bulk-paint marker a frame after it renders, so rows appended
|
|
1026
1275
|
// live (streams, new turns) animate exactly as before.
|
|
1027
1276
|
useLayoutEffect(() => {
|
|
@@ -1056,21 +1305,60 @@ export function MessageTimeline({
|
|
|
1056
1305
|
(entries) => {
|
|
1057
1306
|
const intersecting = entries.some((entry) => entry.isIntersecting);
|
|
1058
1307
|
if (!intersecting) {
|
|
1059
|
-
// Left the top band
|
|
1060
|
-
|
|
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
|
+
}
|
|
1061
1315
|
return;
|
|
1062
1316
|
}
|
|
1063
|
-
|
|
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) {
|
|
1064
1321
|
return;
|
|
1065
1322
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
+
}
|
|
1068
1348
|
},
|
|
1069
1349
|
{ root, rootMargin: OLDER_PREFETCH_ROOT_MARGIN },
|
|
1070
1350
|
);
|
|
1071
1351
|
observer.observe(target);
|
|
1072
1352
|
return () => observer.disconnect();
|
|
1073
|
-
}, [
|
|
1353
|
+
}, [
|
|
1354
|
+
firstGroupKey,
|
|
1355
|
+
hasOlder,
|
|
1356
|
+
loadingOlder,
|
|
1357
|
+
olderBoundaryKey,
|
|
1358
|
+
olderPrefetchArmed,
|
|
1359
|
+
onLoadOlder,
|
|
1360
|
+
requestOlderIfUnderfilled,
|
|
1361
|
+
]);
|
|
1074
1362
|
|
|
1075
1363
|
// History view: page forward when the reader nears the bottom of the current
|
|
1076
1364
|
// non-tip window. Does not pull the whole gap — one density-bounded page.
|
|
@@ -1110,7 +1398,7 @@ export function MessageTimeline({
|
|
|
1110
1398
|
return;
|
|
1111
1399
|
}
|
|
1112
1400
|
const observer = new ResizeObserver(() => {
|
|
1113
|
-
if (resizeFollowRafRef.current
|
|
1401
|
+
if (resizeFollowRafRef.current != null) {
|
|
1114
1402
|
return;
|
|
1115
1403
|
}
|
|
1116
1404
|
resizeFollowRafRef.current = requestFrame(() => {
|
|
@@ -1119,6 +1407,7 @@ export function MessageTimeline({
|
|
|
1119
1407
|
if (!current) {
|
|
1120
1408
|
return;
|
|
1121
1409
|
}
|
|
1410
|
+
requestOlderIfUnderfilled(current);
|
|
1122
1411
|
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
1123
1412
|
return;
|
|
1124
1413
|
}
|
|
@@ -1136,12 +1425,12 @@ export function MessageTimeline({
|
|
|
1136
1425
|
observer.observe(node);
|
|
1137
1426
|
return () => {
|
|
1138
1427
|
observer.disconnect();
|
|
1139
|
-
if (resizeFollowRafRef.current
|
|
1428
|
+
if (resizeFollowRafRef.current != null) {
|
|
1140
1429
|
cancelFrame(resizeFollowRafRef.current);
|
|
1141
1430
|
resizeFollowRafRef.current = null;
|
|
1142
1431
|
}
|
|
1143
1432
|
};
|
|
1144
|
-
}, [autoFollow, driveFollow, snapToBottom]);
|
|
1433
|
+
}, [autoFollow, driveFollow, requestOlderIfUnderfilled, snapToBottom]);
|
|
1145
1434
|
|
|
1146
1435
|
// Entering a non-tip history window: drop any live pin so the page bottom
|
|
1147
1436
|
// cannot re-stick follow across loadNewer. Leaving it (the tip window
|
|
@@ -1237,12 +1526,11 @@ export function MessageTimeline({
|
|
|
1237
1526
|
if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
|
|
1238
1527
|
readerIntentArmRef.current = false;
|
|
1239
1528
|
releasePinFromReader(node);
|
|
1240
|
-
|
|
1241
|
-
olderLoadGateRef.current = "armed";
|
|
1242
|
-
}
|
|
1529
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1243
1530
|
return;
|
|
1244
1531
|
}
|
|
1245
|
-
// 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.
|
|
1246
1534
|
// Reader/extension scroll-up in progress: do not yank — scrollend decides.
|
|
1247
1535
|
// Near-bottom with tipDebt≈0 stays the quiet path (do not broaden follow
|
|
1248
1536
|
// inside PIN_THRESHOLD — that fights small intentional scroll-ups).
|
|
@@ -1289,9 +1577,7 @@ export function MessageTimeline({
|
|
|
1289
1577
|
}
|
|
1290
1578
|
// Re-arm older prefetch only after leaving the top band (scroll down into
|
|
1291
1579
|
// content). Never re-arm/load from continued scroll toward y=0.
|
|
1292
|
-
|
|
1293
|
-
olderLoadGateRef.current = "armed";
|
|
1294
|
-
}
|
|
1580
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1295
1581
|
};
|
|
1296
1582
|
|
|
1297
1583
|
const onScrollEnd = () => {
|
|
@@ -1355,7 +1641,7 @@ export function MessageTimeline({
|
|
|
1355
1641
|
)}
|
|
1356
1642
|
>
|
|
1357
1643
|
<div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
1358
|
-
{groups.length
|
|
1644
|
+
{!groups.length
|
|
1359
1645
|
? (emptyState ?? (
|
|
1360
1646
|
<p className="py-10 text-center text-og-menu text-og-fg-subtle">
|
|
1361
1647
|
No activity yet.
|
|
@@ -1442,7 +1728,8 @@ export function MessageTimeline({
|
|
|
1442
1728
|
<AnimatePresence>
|
|
1443
1729
|
{loadingOlder ||
|
|
1444
1730
|
loadingOldest ||
|
|
1445
|
-
(hasOlder && onJumpToStart && olderPrefetchArmed)
|
|
1731
|
+
(hasOlder && onJumpToStart && olderPrefetchArmed) ||
|
|
1732
|
+
underfillRetryReady ? (
|
|
1446
1733
|
// Floating over the scroller (not a timeline row) so showing and
|
|
1447
1734
|
// hiding it never reflows history under the reader.
|
|
1448
1735
|
<motion.div
|
|
@@ -1452,26 +1739,38 @@ export function MessageTimeline({
|
|
|
1452
1739
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
1453
1740
|
data-og-loading-older=""
|
|
1454
1741
|
aria-live="polite"
|
|
1455
|
-
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"
|
|
1456
1743
|
>
|
|
1457
1744
|
{loadingOlder || loadingOldest ? (
|
|
1458
|
-
<span className=
|
|
1745
|
+
<span className={LOADING_CHIP_CLASS}>
|
|
1459
1746
|
<span className="og-shimmer-text">
|
|
1460
1747
|
{loadingOldest ? "Jumping to start…" : "Loading earlier activity…"}
|
|
1461
1748
|
</span>
|
|
1462
1749
|
</span>
|
|
1463
1750
|
) : null}
|
|
1464
|
-
{hasOlder &&
|
|
1751
|
+
{hasOlder &&
|
|
1752
|
+
!loadingOlder &&
|
|
1753
|
+
!loadingOldest &&
|
|
1754
|
+
(underfillRetryReady || onJumpToStart) ? (
|
|
1465
1755
|
<button
|
|
1466
1756
|
type="button"
|
|
1467
|
-
data-og-
|
|
1468
|
-
|
|
1757
|
+
data-og-retry={underfillRetryReady || undefined}
|
|
1758
|
+
data-og-jump-to-start={!underfillRetryReady || undefined}
|
|
1469
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
|
+
}
|
|
1470
1770
|
applyPinned(false);
|
|
1471
1771
|
pendingJumpToStartRef.current = true;
|
|
1472
1772
|
const seq = ++jumpToStartSeqRef.current;
|
|
1473
|
-
|
|
1474
|
-
void Promise.resolve(onJumpToStart()).then(
|
|
1773
|
+
void Promise.resolve(onJumpToStart!()).then(
|
|
1475
1774
|
() => {
|
|
1476
1775
|
// The commit that swaps in the oldest window consumes
|
|
1477
1776
|
// the flag against the new DOM; this write covers the
|
|
@@ -1500,14 +1799,9 @@ export function MessageTimeline({
|
|
|
1500
1799
|
},
|
|
1501
1800
|
);
|
|
1502
1801
|
}}
|
|
1503
|
-
className=
|
|
1504
|
-
"inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
|
|
1505
|
-
"text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
|
|
1506
|
-
"hover:border-og-border-strong disabled:opacity-60",
|
|
1507
|
-
)}
|
|
1802
|
+
className="rounded-full border border-og-border px-3 py-1.5 text-og-control"
|
|
1508
1803
|
>
|
|
1509
|
-
|
|
1510
|
-
Jump to start
|
|
1804
|
+
{underfillRetryReady ? "Retry earlier activity" : "Jump to start"}
|
|
1511
1805
|
</button>
|
|
1512
1806
|
) : null}
|
|
1513
1807
|
</motion.div>
|
|
@@ -1524,7 +1818,7 @@ export function MessageTimeline({
|
|
|
1524
1818
|
aria-live="polite"
|
|
1525
1819
|
className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
|
|
1526
1820
|
>
|
|
1527
|
-
<span className=
|
|
1821
|
+
<span className={LOADING_CHIP_CLASS}>
|
|
1528
1822
|
<span className="og-shimmer-text">Loading later activity…</span>
|
|
1529
1823
|
</span>
|
|
1530
1824
|
</motion.div>
|
|
@@ -1810,8 +2104,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1810
2104
|
// turn that is the second layer — quiet nested chips under the outer turn
|
|
1811
2105
|
// summary when contiguous activity naturally clusters (≥2 only).
|
|
1812
2106
|
const activityShouldFold =
|
|
1813
|
-
group.kind === "activity" &&
|
|
1814
|
-
Boolean(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
|
|
2107
|
+
group.kind === "activity" && !!(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
|
|
1815
2108
|
const containsGeneratedImage = timelineGroupContainsGeneratedImage(group);
|
|
1816
2109
|
// Latch live→folded so a top-level shell that was already mounted open can
|
|
1817
2110
|
// start the settle beat without remounting bare rail → wrapper.
|
|
@@ -1832,7 +2125,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1832
2125
|
);
|
|
1833
2126
|
}
|
|
1834
2127
|
const settleFold =
|
|
1835
|
-
group.kind === "turn" ?
|
|
2128
|
+
group.kind === "turn" ? !!(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle;
|
|
1836
2129
|
switch (group.kind) {
|
|
1837
2130
|
case "activity":
|
|
1838
2131
|
if (insideTurn) {
|
|
@@ -2194,7 +2487,7 @@ export function trailingAgentTextAfterTurn(
|
|
|
2194
2487
|
if (next?.kind === "item" && next.item.kind === "agent-message") {
|
|
2195
2488
|
const turnIds = collectTurnIdsFromGroups(group.groups);
|
|
2196
2489
|
// Degenerate body with no turnIds: do not guess — safer than lifting wrong.
|
|
2197
|
-
if (turnIds.size
|
|
2490
|
+
if (!turnIds.size) {
|
|
2198
2491
|
return undefined;
|
|
2199
2492
|
}
|
|
2200
2493
|
if (!next.item.turnId || !turnIds.has(next.item.turnId)) {
|
|
@@ -2213,7 +2506,7 @@ function collectTurnCopyText(
|
|
|
2213
2506
|
const parts = [collectAgentMessageText(groups), trailingAgentText?.trim() ?? ""].filter(
|
|
2214
2507
|
(part) => part.length > 0,
|
|
2215
2508
|
);
|
|
2216
|
-
if (parts.length
|
|
2509
|
+
if (!parts.length) {
|
|
2217
2510
|
return undefined;
|
|
2218
2511
|
}
|
|
2219
2512
|
return parts.join("\n\n");
|
|
@@ -2298,11 +2591,11 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2298
2591
|
const trigger =
|
|
2299
2592
|
item.trigger && item.phase !== "started" ? COMPACTION_TRIGGER_LABEL[item.trigger] : null;
|
|
2300
2593
|
const before =
|
|
2301
|
-
item.estimatedTokensBefore
|
|
2594
|
+
item.estimatedTokensBefore != null
|
|
2302
2595
|
? Math.round(item.estimatedTokensBefore).toLocaleString("en-US")
|
|
2303
2596
|
: null;
|
|
2304
2597
|
const after =
|
|
2305
|
-
item.estimatedTokensAfter
|
|
2598
|
+
item.estimatedTokensAfter != null
|
|
2306
2599
|
? Math.round(item.estimatedTokensAfter).toLocaleString("en-US")
|
|
2307
2600
|
: null;
|
|
2308
2601
|
const title =
|
|
@@ -2323,7 +2616,7 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2323
2616
|
item.phase === "skipped" && item.skipReason === "summarization_failed"
|
|
2324
2617
|
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
2325
2618
|
: item.phase === "started"
|
|
2326
|
-
?
|
|
2619
|
+
? WAITING_PILL_CLASS
|
|
2327
2620
|
: "border-og-border bg-og-surface-1 text-og-fg-muted";
|
|
2328
2621
|
return (
|
|
2329
2622
|
<div className={cn(enter && "animate-og-enter", "flex justify-center")}>
|
|
@@ -2403,7 +2696,7 @@ function UserMessageRow({
|
|
|
2403
2696
|
className="w-fit max-w-full min-w-0"
|
|
2404
2697
|
trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
|
|
2405
2698
|
>
|
|
2406
|
-
<div className=
|
|
2699
|
+
<div className={MESSAGE_BUBBLE_CLASS}>
|
|
2407
2700
|
{item.text ? (
|
|
2408
2701
|
<div data-og-annotation-source-key={item.annotationSource?.eventId}>
|
|
2409
2702
|
{renderMessageText ? (
|
|
@@ -2525,7 +2818,7 @@ function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
|
|
|
2525
2818
|
className="w-fit max-w-[90%] min-w-0 sm:max-w-[82%]"
|
|
2526
2819
|
trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
|
|
2527
2820
|
>
|
|
2528
|
-
<div className=
|
|
2821
|
+
<div className={MESSAGE_BUBBLE_CLASS}>
|
|
2529
2822
|
<p className="text-og-xs font-medium text-og-fg-subtle">{settledLabel}</p>
|
|
2530
2823
|
{item.response.outcome === "answered" ? (
|
|
2531
2824
|
<div className="mt-1.5 space-y-3">
|
|
@@ -2695,9 +2988,7 @@ function WorkerCompletionRow({
|
|
|
2695
2988
|
// payloads can carry a leftover pausedReason/rationale from earlier in the
|
|
2696
2989
|
// worker's life, and a "Worker completed" card must not show a pause section.
|
|
2697
2990
|
const showPausedReason =
|
|
2698
|
-
item.childStatus !== "failed" &&
|
|
2699
|
-
item.goalStatus === "paused" &&
|
|
2700
|
-
Boolean(item.pausedReason?.trim());
|
|
2991
|
+
item.childStatus !== "failed" && item.goalStatus === "paused" && !!item.pausedReason?.trim();
|
|
2701
2992
|
const details: { label: string; value: string; muted?: boolean }[] = [
|
|
2702
2993
|
...(item.text.trim() ? [{ label: "Report", value: item.text.trim() }] : []),
|
|
2703
2994
|
...(item.evidence?.trim()
|
|
@@ -2836,14 +3127,14 @@ const GOAL_META: Record<GoalItem["action"], GoalMeta> = {
|
|
|
2836
3127
|
},
|
|
2837
3128
|
paused: {
|
|
2838
3129
|
label: "Goal paused",
|
|
2839
|
-
pill:
|
|
3130
|
+
pill: WAITING_PILL_CLASS,
|
|
2840
3131
|
icon: PauseIcon,
|
|
2841
3132
|
},
|
|
2842
3133
|
resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
|
|
2843
3134
|
cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
|
|
2844
3135
|
held: {
|
|
2845
3136
|
label: "Goal held",
|
|
2846
|
-
pill:
|
|
3137
|
+
pill: WAITING_PILL_CLASS,
|
|
2847
3138
|
icon: PauseCircleIcon,
|
|
2848
3139
|
},
|
|
2849
3140
|
continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
|
|
@@ -3041,7 +3332,7 @@ function NoticeRow({ item }: { item: NoticeItem }) {
|
|
|
3041
3332
|
item.tone === "failed"
|
|
3042
3333
|
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
3043
3334
|
: item.tone === "waiting"
|
|
3044
|
-
?
|
|
3335
|
+
? WAITING_PILL_CLASS
|
|
3045
3336
|
: "border-og-border bg-og-surface-1 text-og-fg-muted";
|
|
3046
3337
|
return (
|
|
3047
3338
|
<div
|
|
@@ -3174,7 +3465,7 @@ function AuthNeededRow({
|
|
|
3174
3465
|
onClick={() => void start()}
|
|
3175
3466
|
disabled={busy}
|
|
3176
3467
|
className={cn(
|
|
3177
|
-
|
|
3468
|
+
PRIMARY_ACTION_CLASS,
|
|
3178
3469
|
"transition-colors hover:bg-og-accent-strong disabled:opacity-70 pointer-coarse:min-h-9",
|
|
3179
3470
|
)}
|
|
3180
3471
|
>
|
|
@@ -3187,7 +3478,7 @@ function AuthNeededRow({
|
|
|
3187
3478
|
rel="noreferrer"
|
|
3188
3479
|
target="_blank"
|
|
3189
3480
|
className={cn(
|
|
3190
|
-
|
|
3481
|
+
PRIMARY_ACTION_CLASS,
|
|
3191
3482
|
"transition-colors hover:bg-og-accent-strong pointer-coarse:min-h-9",
|
|
3192
3483
|
)}
|
|
3193
3484
|
>
|
|
@@ -3250,7 +3541,7 @@ function AuthProviderLogo({ src, label }: { src: string | null; label: string })
|
|
|
3250
3541
|
`capabilityMonogram` so the reconnect tile reads like every other logo tile. */
|
|
3251
3542
|
function monogram(label: string): string {
|
|
3252
3543
|
const words = label.trim().split(/\s+/).filter(Boolean);
|
|
3253
|
-
if (words.length
|
|
3544
|
+
if (!words.length) {
|
|
3254
3545
|
return "?";
|
|
3255
3546
|
}
|
|
3256
3547
|
if (words.length === 1) {
|