@opengeni/react 0.34.2 → 0.35.2
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/dist/{chunk-GZ4KR67U.js → chunk-ALA3UGWB.js} +2 -2
- package/dist/chunk-ATSTR5P3.js +138 -0
- package/dist/chunk-ATSTR5P3.js.map +1 -0
- package/dist/{chunk-L7R5RK6A.js → chunk-AXQQ2V2I.js} +1312 -926
- package/dist/chunk-AXQQ2V2I.js.map +1 -0
- package/dist/{chunk-KZRUYCL4.js → chunk-FT2TXNGZ.js} +5 -2
- package/dist/chunk-FT2TXNGZ.js.map +1 -0
- package/dist/{chunk-TCCE5ZU5.js → chunk-J2RVJ6JX.js} +39 -9
- package/dist/chunk-J2RVJ6JX.js.map +1 -0
- package/dist/{chunk-4QOQ2DSR.js → chunk-LAKLF4PA.js} +14 -1
- package/dist/chunk-LAKLF4PA.js.map +1 -0
- package/dist/{chunk-A2WITZTI.js → chunk-OKKMZDQF.js} +3 -2
- package/dist/chunk-OKKMZDQF.js.map +1 -0
- package/dist/{chunk-MS3FBZ6A.js → chunk-PVW56EVR.js} +105 -62
- package/dist/chunk-PVW56EVR.js.map +1 -0
- package/dist/components/chat-composer.d.ts +2 -1
- package/dist/components/composer.d.ts +8 -2
- package/dist/components/copy-button.d.ts +4 -2
- package/dist/components/tip-follow.d.ts +3 -0
- package/dist/composer.d.ts +2 -1
- package/dist/composer.js +4 -3
- package/dist/hooks/use-turn-queue.d.ts +3 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +105 -101
- package/dist/index.js.map +1 -1
- package/dist/lib/format.d.ts +2 -0
- package/dist/machines.js +2 -2
- package/dist/session-ui.js +4 -3
- package/dist/session.js +4 -4
- package/dist/timeline/index.d.ts +1 -1
- package/dist/timeline/parsers.d.ts +5 -0
- package/dist/timeline/projection.d.ts +1 -12
- package/dist/timeline/tool-display-name.d.ts +17 -6
- package/dist/workstream-control-event.d.ts +6 -0
- package/package.json +2 -2
- package/src/components/chat-composer.tsx +1 -1
- package/src/components/composer-transcription-control.tsx +70 -57
- package/src/components/composer.tsx +197 -126
- package/src/components/copy-button.tsx +27 -8
- package/src/components/message-timeline.tsx +385 -71
- package/src/components/tip-follow.ts +15 -0
- package/src/composer.ts +1 -1
- package/src/hooks/use-composer.ts +7 -1
- package/src/hooks/use-turn-queue.ts +4 -0
- package/src/index.ts +2 -1
- package/src/lib/format.ts +14 -0
- package/src/timeline/index.ts +2 -0
- package/src/timeline/parsers.ts +18 -1
- package/src/timeline/projection.ts +51 -20
- package/src/timeline/registry.ts +15 -2
- package/src/timeline/shared.tsx +30 -28
- package/src/timeline/tool-display-name.ts +30 -11
- package/src/timeline/tool-renderers.tsx +347 -16
- package/src/timeline/turn-summary.tsx +5 -2
- package/src/workstream-control-event.ts +6 -0
- package/dist/chunk-4QOQ2DSR.js.map +0 -1
- package/dist/chunk-A2WITZTI.js.map +0 -1
- package/dist/chunk-KZRUYCL4.js.map +0 -1
- package/dist/chunk-L7R5RK6A.js.map +0 -1
- package/dist/chunk-MS3FBZ6A.js.map +0 -1
- package/dist/chunk-TCCE5ZU5.js.map +0 -1
- /package/dist/{chunk-GZ4KR67U.js.map → chunk-ALA3UGWB.js.map} +0 -0
|
@@ -22,6 +22,7 @@ import type { ComponentType } from "react";
|
|
|
22
22
|
import { AnimatePresence, motion } from "motion/react";
|
|
23
23
|
import { Collapsible } from "radix-ui";
|
|
24
24
|
import {
|
|
25
|
+
Component,
|
|
25
26
|
memo,
|
|
26
27
|
useCallback,
|
|
27
28
|
useEffect,
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
type ReactNode,
|
|
33
34
|
} from "react";
|
|
34
35
|
import { cn } from "../lib/cn";
|
|
35
|
-
import { formatRelativeTime, truncate } from "../lib/format";
|
|
36
|
+
import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
|
|
36
37
|
import { prefersReducedMotion } from "../lib/motion";
|
|
37
38
|
import { Markdown } from "./markdown";
|
|
38
39
|
import {
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
tipFollowCancel,
|
|
42
43
|
tipFollowCompensateShrink,
|
|
43
44
|
tipFollowStep,
|
|
45
|
+
supportsScrollEndEvent,
|
|
44
46
|
TIP_FOLLOW_READER_UP_EPS_PX,
|
|
45
47
|
TIP_FOLLOW_SHRINK_EPS_PX,
|
|
46
48
|
type TipFollowState,
|
|
@@ -158,12 +160,16 @@ export type MessageTimelineProps = {
|
|
|
158
160
|
* replaces was the "content is hidden, then pops in in batches" wobble.)
|
|
159
161
|
*
|
|
160
162
|
* Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
|
|
161
|
-
* -
|
|
163
|
+
* - Load/remount: hidden until tip is hard-snapped across a short settle; then
|
|
164
|
+
* reveal. Live tip: DOM grows immediately; the camera eases down (not
|
|
162
165
|
* tip-glued feed-forward — that is the one-line yank).
|
|
163
166
|
* - One continuous follow while hot (faster τ when behind); sleeps when cold.
|
|
164
167
|
* - While pinned, tip-debt from growth/collapse must NEVER unpin — only
|
|
165
|
-
* wheel/keys/pointer-armed scroll-up
|
|
166
|
-
*
|
|
168
|
+
* wheel/keys/pointer-armed scroll-up, or a settled scrollend away from the
|
|
169
|
+
* tip while the tip-follow camera is idle (Vimium / unfocused PageUp).
|
|
170
|
+
* Height shrink compensates scrollTop by Δh (collapse owns motion); tip-ease
|
|
171
|
+
* pauses briefly so the two don't fight. Programmatic camera writes are
|
|
172
|
+
* tagged so their scroll echoes never count as leave.
|
|
167
173
|
* - overflow-anchor off while pinned so the browser cannot instant-correct.
|
|
168
174
|
* - Scrolled up → history prepends restore via the retained group anchor
|
|
169
175
|
* (offsetTop delta); loadOlder can truncate the tip, so scrollHeight delta
|
|
@@ -288,9 +294,10 @@ export function MessageTimeline({
|
|
|
288
294
|
const olderLoadGateRef = useRef<"armed" | "cooling">("armed");
|
|
289
295
|
const resizeFollowRafRef = useRef<number | null>(null);
|
|
290
296
|
const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
|
|
291
|
-
// Content stays invisible until
|
|
292
|
-
//
|
|
293
|
-
//
|
|
297
|
+
// Content stays invisible until the tip is hard-parked across a short
|
|
298
|
+
// post-commit settle (two rAFs). That absorbs sync late layout while hidden
|
|
299
|
+
// so load/remount does not ease into the tip — live tip-follow is unchanged
|
|
300
|
+
// once revealed. A flash of the window's TOP is still structurally impossible.
|
|
294
301
|
const [revealed, setRevealed] = useState(false);
|
|
295
302
|
// Mirror `pinned` into a ref, written ONLY by applyPinned, so the
|
|
296
303
|
// ResizeObserver rAF (a stable closure) reads the live value and a snap can
|
|
@@ -325,11 +332,24 @@ export function MessageTimeline({
|
|
|
325
332
|
const lastScrollHeightRef = useRef(0);
|
|
326
333
|
const lastClientHeightRef = useRef(0);
|
|
327
334
|
/**
|
|
328
|
-
* Armed by pointerdown on the scroller.
|
|
329
|
-
* while armed
|
|
330
|
-
*
|
|
335
|
+
* Armed by pointerdown on the scroller. Immediate geometric scroll-up unpins
|
|
336
|
+
* while armed (scrollbar / touch drag). Wheel/keys unpin directly. Extension
|
|
337
|
+
* jumps (Vimium) settle via scrollend while the camera is idle.
|
|
331
338
|
*/
|
|
332
339
|
const readerIntentArmRef = useRef(false);
|
|
340
|
+
/**
|
|
341
|
+
* Count of camera/snap scrollTop writes whose scroll echoes are not yet
|
|
342
|
+
* consumed. A boolean was wrong when the browser coalesced two writes into
|
|
343
|
+
* one scroll event (or fired two) — use a count, and clear to 0 on echo.
|
|
344
|
+
*/
|
|
345
|
+
const programmaticScrollRef = useRef(0);
|
|
346
|
+
/**
|
|
347
|
+
* Unarmed scroll-away observed; waiting for scrollend (or rAF fallback).
|
|
348
|
+
* Blocks layout tip-follow so a stream token cannot yank before leave settles.
|
|
349
|
+
*/
|
|
350
|
+
const pendingReaderLeaveRef = useRef(false);
|
|
351
|
+
/** Fallback leave check when `scrollend` is missing (one rAF, not a timer). */
|
|
352
|
+
const leaveFallbackRafRef = useRef<number | null>(null);
|
|
333
353
|
// Resting fold state per durable group id (see fold-memory.ts). Outlives the
|
|
334
354
|
// deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
|
|
335
355
|
// that already settled closed — or that the reader closed — never reopens.
|
|
@@ -369,6 +389,27 @@ export function MessageTimeline({
|
|
|
369
389
|
lastClientHeightRef.current = node.clientHeight;
|
|
370
390
|
}, []);
|
|
371
391
|
|
|
392
|
+
const writeScrollTop = useCallback((node: HTMLElement, top: number) => {
|
|
393
|
+
const next = Math.max(0, top);
|
|
394
|
+
if (node.scrollTop === next) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
programmaticScrollRef.current += 1;
|
|
398
|
+
node.scrollTop = next;
|
|
399
|
+
}, []);
|
|
400
|
+
|
|
401
|
+
const cancelLeaveFallback = useCallback(() => {
|
|
402
|
+
if (leaveFallbackRafRef.current !== null) {
|
|
403
|
+
cancelFrame(leaveFallbackRafRef.current);
|
|
404
|
+
leaveFallbackRafRef.current = null;
|
|
405
|
+
}
|
|
406
|
+
}, []);
|
|
407
|
+
|
|
408
|
+
const clearPendingReaderLeave = useCallback(() => {
|
|
409
|
+
pendingReaderLeaveRef.current = false;
|
|
410
|
+
cancelLeaveFallback();
|
|
411
|
+
}, [cancelLeaveFallback]);
|
|
412
|
+
|
|
372
413
|
const stopFollow = useCallback(() => {
|
|
373
414
|
followRef.current = tipFollowCancel(followRef.current);
|
|
374
415
|
if (followFrameRef.current !== null) {
|
|
@@ -377,7 +418,7 @@ export function MessageTimeline({
|
|
|
377
418
|
}
|
|
378
419
|
}, []);
|
|
379
420
|
|
|
380
|
-
/** Reader left the tip — wheel, keyboard,
|
|
421
|
+
/** Reader left the tip — wheel, keyboard, pointer-armed scroll-up, or scrollend. */
|
|
381
422
|
const releasePinFromReader = useCallback(
|
|
382
423
|
(node?: HTMLElement | null) => {
|
|
383
424
|
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
@@ -388,6 +429,7 @@ export function MessageTimeline({
|
|
|
388
429
|
return;
|
|
389
430
|
}
|
|
390
431
|
readerIntentArmRef.current = false;
|
|
432
|
+
clearPendingReaderLeave();
|
|
391
433
|
stopFollow();
|
|
392
434
|
applyPinned(false);
|
|
393
435
|
if (wantPinRef.current) {
|
|
@@ -398,9 +440,51 @@ export function MessageTimeline({
|
|
|
398
440
|
setOlderPrefetchArmed(true);
|
|
399
441
|
}
|
|
400
442
|
},
|
|
401
|
-
[autoFollow, applyPinned, stopFollow],
|
|
443
|
+
[autoFollow, applyPinned, clearPendingReaderLeave, stopFollow],
|
|
402
444
|
);
|
|
403
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Settled away from the tip while the camera is idle — Vimium / unfocused
|
|
448
|
+
* PageUp. Folds are recovered by layout tip-follow before this fires at tip.
|
|
449
|
+
*/
|
|
450
|
+
const releasePinAfterScrollSettled = useCallback(
|
|
451
|
+
(node: HTMLElement) => {
|
|
452
|
+
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (programmaticScrollRef.current > 0) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (followRef.current.running || followFrameRef.current !== null) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (isNearBottom(node) || maxScrollOf(node) <= 1) {
|
|
462
|
+
clearPendingReaderLeave();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
releasePinFromReader(node);
|
|
466
|
+
if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
467
|
+
olderLoadGateRef.current = "armed";
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
[autoFollow, clearPendingReaderLeave, releasePinFromReader],
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const scheduleLeaveFallback = useCallback(() => {
|
|
474
|
+
// Prefer scrollend when the engine supports it.
|
|
475
|
+
if (supportsScrollEndEvent()) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
cancelLeaveFallback();
|
|
479
|
+
leaveFallbackRafRef.current = requestFrame(() => {
|
|
480
|
+
leaveFallbackRafRef.current = null;
|
|
481
|
+
const current = scrollRef.current;
|
|
482
|
+
if (current) {
|
|
483
|
+
releasePinAfterScrollSettled(current);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
}, [cancelLeaveFallback, releasePinAfterScrollSettled]);
|
|
487
|
+
|
|
404
488
|
const onWheel = (event: {
|
|
405
489
|
deltaY: number;
|
|
406
490
|
deltaX: number;
|
|
@@ -455,14 +539,15 @@ export function MessageTimeline({
|
|
|
455
539
|
const snapToBottom = useCallback(
|
|
456
540
|
(node: HTMLElement) => {
|
|
457
541
|
stopFollow();
|
|
458
|
-
|
|
542
|
+
cancelLeaveFallback();
|
|
543
|
+
writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
|
|
459
544
|
syncScrollBaseline(node);
|
|
460
545
|
followRef.current = {
|
|
461
546
|
...followRef.current,
|
|
462
547
|
lastHeight: node.scrollHeight,
|
|
463
548
|
};
|
|
464
549
|
},
|
|
465
|
-
[stopFollow, syncScrollBaseline],
|
|
550
|
+
[cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
|
|
466
551
|
);
|
|
467
552
|
|
|
468
553
|
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
|
|
@@ -472,6 +557,12 @@ export function MessageTimeline({
|
|
|
472
557
|
stopFollow();
|
|
473
558
|
return;
|
|
474
559
|
}
|
|
560
|
+
// Reader/extension leave in flight — do not yank back before scrollend.
|
|
561
|
+
if (pendingReaderLeaveRef.current) {
|
|
562
|
+
stopFollow();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
cancelLeaveFallback();
|
|
475
566
|
// Prefer the rAF timestamp so ease integrates against vsync (and tests
|
|
476
567
|
// can advance a synthetic clock via requestAnimationFrame callbacks).
|
|
477
568
|
const now =
|
|
@@ -493,9 +584,7 @@ export function MessageTimeline({
|
|
|
493
584
|
node.scrollHeight,
|
|
494
585
|
node.clientHeight,
|
|
495
586
|
);
|
|
496
|
-
|
|
497
|
-
node.scrollTop = nextTop;
|
|
498
|
-
}
|
|
587
|
+
writeScrollTop(node, nextTop);
|
|
499
588
|
syncScrollBaseline(node);
|
|
500
589
|
followRef.current = {
|
|
501
590
|
...followRef.current,
|
|
@@ -531,11 +620,10 @@ export function MessageTimeline({
|
|
|
531
620
|
revealed: revealedRef.current,
|
|
532
621
|
});
|
|
533
622
|
followRef.current = result.state;
|
|
534
|
-
|
|
535
|
-
node.scrollTop = result.scrollTop;
|
|
536
|
-
}
|
|
623
|
+
writeScrollTop(node, result.scrollTop);
|
|
537
624
|
syncScrollBaseline(node);
|
|
538
625
|
if (result.state.running) {
|
|
626
|
+
cancelLeaveFallback();
|
|
539
627
|
if (followFrameRef.current === null) {
|
|
540
628
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
541
629
|
followFrameRef.current = null;
|
|
@@ -550,11 +638,12 @@ export function MessageTimeline({
|
|
|
550
638
|
followFrameRef.current = null;
|
|
551
639
|
}
|
|
552
640
|
},
|
|
553
|
-
[stopFollow, syncScrollBaseline],
|
|
641
|
+
[cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
|
|
554
642
|
);
|
|
555
643
|
driveFollowRef.current = driveFollow;
|
|
556
644
|
|
|
557
645
|
useEffect(() => stopFollow, [stopFollow]);
|
|
646
|
+
useEffect(() => () => cancelLeaveFallback(), [cancelLeaveFallback]);
|
|
558
647
|
|
|
559
648
|
// The single post-commit scroll authority. Runs after EVERY commit (no dep
|
|
560
649
|
// list): any commit may change content height, and the decision is cheap.
|
|
@@ -578,7 +667,7 @@ export function MessageTimeline({
|
|
|
578
667
|
// prepend correction (it would shift the reader away from the top).
|
|
579
668
|
pendingJumpToStartRef.current = false;
|
|
580
669
|
stopFollow();
|
|
581
|
-
node
|
|
670
|
+
writeScrollTop(node, 0);
|
|
582
671
|
} else if (wantPinRef.current && !hasNewer) {
|
|
583
672
|
// Jump-to-latest was pressed on a history window and the tip window is
|
|
584
673
|
// in THIS commit — consume pre-paint so the first tip frame is already
|
|
@@ -589,8 +678,19 @@ export function MessageTimeline({
|
|
|
589
678
|
snapToBottom(node);
|
|
590
679
|
}
|
|
591
680
|
} else if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
592
|
-
//
|
|
593
|
-
|
|
681
|
+
// Load/remount (still hidden): hard-park. Live tip after reveal: ease.
|
|
682
|
+
// Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
|
|
683
|
+
// Flat/shrink commits (fold) still recover — height did not grow under us.
|
|
684
|
+
if (!revealedRef.current) {
|
|
685
|
+
snapToBottom(node);
|
|
686
|
+
} else if (pendingReaderLeaveRef.current) {
|
|
687
|
+
if (node.scrollHeight <= lastScrollHeightRef.current) {
|
|
688
|
+
clearPendingReaderLeave();
|
|
689
|
+
driveFollow(node);
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
driveFollow(node);
|
|
693
|
+
}
|
|
594
694
|
} else if (prepended) {
|
|
595
695
|
// Keep the reader on the same retained rows. Prefer the offsetTop delta
|
|
596
696
|
// of the group that still holds the previous first item — that stays
|
|
@@ -622,7 +722,7 @@ export function MessageTimeline({
|
|
|
622
722
|
if (delta !== null) {
|
|
623
723
|
const expected = previousScrollTop + delta;
|
|
624
724
|
if (Math.abs(node.scrollTop - expected) > 2) {
|
|
625
|
-
node
|
|
725
|
+
writeScrollTop(node, expected);
|
|
626
726
|
}
|
|
627
727
|
}
|
|
628
728
|
}
|
|
@@ -661,11 +761,46 @@ export function MessageTimeline({
|
|
|
661
761
|
}
|
|
662
762
|
groupOffsetByKeyRef.current = nextOffsetByKey;
|
|
663
763
|
}
|
|
664
|
-
if (!revealed && groups.length > 0) {
|
|
665
|
-
setRevealed(true);
|
|
666
|
-
}
|
|
667
764
|
});
|
|
668
765
|
|
|
766
|
+
// First paint / session remount: keep the scroller hidden, snap to tip for
|
|
767
|
+
// two animation frames (late sync layout), then reveal. Does not change the
|
|
768
|
+
// tip-follow ease law used once `revealed` is true.
|
|
769
|
+
useLayoutEffect(() => {
|
|
770
|
+
if (revealed || allGroups.length === 0) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
let cancelled = false;
|
|
774
|
+
let frame2 = 0;
|
|
775
|
+
const park = () => {
|
|
776
|
+
const node = scrollRef.current;
|
|
777
|
+
if (node && autoFollow && pinnedRef.current && !hasNewerRef.current) {
|
|
778
|
+
snapToBottom(node);
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
park();
|
|
782
|
+
const frame1 = requestFrame(() => {
|
|
783
|
+
if (cancelled) {
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
park();
|
|
787
|
+
frame2 = requestFrame(() => {
|
|
788
|
+
if (cancelled) {
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
park();
|
|
792
|
+
setRevealed(true);
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
return () => {
|
|
796
|
+
cancelled = true;
|
|
797
|
+
cancelFrame(frame1);
|
|
798
|
+
if (frame2 !== 0) {
|
|
799
|
+
cancelFrame(frame2);
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
}, [revealed, allGroups.length, autoFollow, snapToBottom]);
|
|
803
|
+
|
|
669
804
|
// A cleared timeline (stream identity change) re-arms the reveal + prefetch
|
|
670
805
|
// gate and returns to bottom-follow for the next session's first paint.
|
|
671
806
|
useLayoutEffect(() => {
|
|
@@ -792,9 +927,15 @@ export function MessageTimeline({
|
|
|
792
927
|
if (!current) {
|
|
793
928
|
return;
|
|
794
929
|
}
|
|
795
|
-
if (autoFollow
|
|
796
|
-
|
|
930
|
+
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
931
|
+
return;
|
|
797
932
|
}
|
|
933
|
+
// Still unveiling the first tip frame: hard-park (no ease settle).
|
|
934
|
+
if (!revealedRef.current) {
|
|
935
|
+
snapToBottom(current);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
driveFollow(current);
|
|
798
939
|
});
|
|
799
940
|
});
|
|
800
941
|
observer.observe(inner);
|
|
@@ -808,7 +949,7 @@ export function MessageTimeline({
|
|
|
808
949
|
resizeFollowRafRef.current = null;
|
|
809
950
|
}
|
|
810
951
|
};
|
|
811
|
-
}, [autoFollow, driveFollow]);
|
|
952
|
+
}, [autoFollow, driveFollow, snapToBottom]);
|
|
812
953
|
|
|
813
954
|
// Entering a non-tip history window: drop any live pin so the page bottom
|
|
814
955
|
// cannot re-stick follow across loadNewer. Leaving it (the tip window
|
|
@@ -838,9 +979,9 @@ export function MessageTimeline({
|
|
|
838
979
|
}
|
|
839
980
|
}, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
|
|
840
981
|
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
982
|
+
// Pinned: layout/camera recover tip debt; wheel/keys/pointer-arm unpin
|
|
983
|
+
// immediately; extension jumps settle via scrollend (or one-rAF fallback).
|
|
984
|
+
// Do not tip-follow-yank an in-flight unarmed scroll-away — that ate Vimium.
|
|
844
985
|
const onScroll = () => {
|
|
845
986
|
const node = scrollRef.current;
|
|
846
987
|
if (!node) {
|
|
@@ -857,17 +998,34 @@ export function MessageTimeline({
|
|
|
857
998
|
const heightShrunk =
|
|
858
999
|
followRef.current.lastHeight > 0 &&
|
|
859
1000
|
nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
|
|
1001
|
+
// Consume all pending camera-write echoes (browsers may coalesce writes).
|
|
1002
|
+
const programmatic = programmaticScrollRef.current > 0;
|
|
1003
|
+
if (programmatic) {
|
|
1004
|
+
programmaticScrollRef.current = 0;
|
|
1005
|
+
}
|
|
860
1006
|
|
|
861
1007
|
if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
862
1008
|
// Fold / composer shrink: compensate before baseline sync so driveFollow
|
|
863
1009
|
// still sees the pre-shrink scrollTop (avoid double-subtract after clamp).
|
|
864
1010
|
if (heightShrunk || maxFell) {
|
|
865
1011
|
readerIntentArmRef.current = false;
|
|
1012
|
+
clearPendingReaderLeave();
|
|
866
1013
|
driveFollow(node);
|
|
867
1014
|
return;
|
|
868
1015
|
}
|
|
1016
|
+
if (programmatic) {
|
|
1017
|
+
syncScrollBaseline(node);
|
|
1018
|
+
followRef.current = {
|
|
1019
|
+
...followRef.current,
|
|
1020
|
+
lastHeight: nextHeight,
|
|
1021
|
+
};
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
869
1024
|
syncScrollBaseline(node);
|
|
870
1025
|
const nearBottomPinned = isNearBottom(node);
|
|
1026
|
+
if (nearBottomPinned) {
|
|
1027
|
+
clearPendingReaderLeave();
|
|
1028
|
+
}
|
|
871
1029
|
// Pointer-dragged scroll-up away from tip. Layout churn never arms this.
|
|
872
1030
|
if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
|
|
873
1031
|
readerIntentArmRef.current = false;
|
|
@@ -877,9 +1035,15 @@ export function MessageTimeline({
|
|
|
877
1035
|
}
|
|
878
1036
|
return;
|
|
879
1037
|
}
|
|
880
|
-
//
|
|
881
|
-
|
|
882
|
-
|
|
1038
|
+
// Tip grew under a still viewport (no reader-up): ease back to the tip.
|
|
1039
|
+
// Reader/extension scroll-up in progress: do not yank — scrollend decides.
|
|
1040
|
+
if (!nearBottomPinned && readerUp <= TIP_FOLLOW_READER_UP_EPS_PX) {
|
|
1041
|
+
if (!pendingReaderLeaveRef.current) {
|
|
1042
|
+
driveFollow(node);
|
|
1043
|
+
}
|
|
1044
|
+
} else if (!nearBottomPinned && readerUp > TIP_FOLLOW_READER_UP_EPS_PX) {
|
|
1045
|
+
pendingReaderLeaveRef.current = true;
|
|
1046
|
+
scheduleLeaveFallback();
|
|
883
1047
|
} else {
|
|
884
1048
|
followRef.current = {
|
|
885
1049
|
...followRef.current,
|
|
@@ -922,6 +1086,20 @@ export function MessageTimeline({
|
|
|
922
1086
|
}
|
|
923
1087
|
};
|
|
924
1088
|
|
|
1089
|
+
const onScrollEnd = () => {
|
|
1090
|
+
const node = scrollRef.current;
|
|
1091
|
+
if (!node) {
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
cancelLeaveFallback();
|
|
1095
|
+
if (programmaticScrollRef.current > 0) {
|
|
1096
|
+
programmaticScrollRef.current = 0;
|
|
1097
|
+
syncScrollBaseline(node);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
releasePinAfterScrollSettled(node);
|
|
1101
|
+
};
|
|
1102
|
+
|
|
925
1103
|
return (
|
|
926
1104
|
<LightboxProvider>
|
|
927
1105
|
<FoldMemoryProvider value={foldMemoryRef.current}>
|
|
@@ -935,6 +1113,7 @@ export function MessageTimeline({
|
|
|
935
1113
|
ref={scrollRef}
|
|
936
1114
|
tabIndex={-1}
|
|
937
1115
|
onScroll={onScroll}
|
|
1116
|
+
onScrollEnd={onScrollEnd}
|
|
938
1117
|
onWheel={onWheel}
|
|
939
1118
|
onPointerDown={onPointerDown}
|
|
940
1119
|
onKeyDown={onKeyDown}
|
|
@@ -980,21 +1159,34 @@ export function MessageTimeline({
|
|
|
980
1159
|
: 0;
|
|
981
1160
|
return (
|
|
982
1161
|
<div key={key} data-og-timeline-group-anchor="" data-og-group-key={key}>
|
|
983
|
-
<
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1162
|
+
<TimelineGroupRenderBoundary
|
|
1163
|
+
resetKeys={[
|
|
1164
|
+
group,
|
|
1165
|
+
renderMessageText,
|
|
1166
|
+
onOpenSession,
|
|
1167
|
+
onMemoryClick,
|
|
1168
|
+
onReconnect,
|
|
1169
|
+
resolveProviderLogo,
|
|
1170
|
+
toolRegistry,
|
|
1171
|
+
turnSummary,
|
|
1172
|
+
]}
|
|
1173
|
+
>
|
|
1174
|
+
<TimelineGroupView
|
|
1175
|
+
group={group}
|
|
1176
|
+
renderMessageText={renderMessageText}
|
|
1177
|
+
onOpenSession={onOpenSession}
|
|
1178
|
+
onMemoryClick={onMemoryClick}
|
|
1179
|
+
onReconnect={onReconnect}
|
|
1180
|
+
resolveProviderLogo={resolveProviderLogo}
|
|
1181
|
+
toolRegistry={toolRegistry}
|
|
1182
|
+
turnSummary={turnSummary}
|
|
1183
|
+
foldLiveCluster={isAgentProgress(next)}
|
|
1184
|
+
trailingAgentText={trailingAgentTextAfterTurn(group, next)}
|
|
1185
|
+
contextCompactionCount={
|
|
1186
|
+
contextCompactionCount > 0 ? contextCompactionCount : undefined
|
|
1187
|
+
}
|
|
1188
|
+
/>
|
|
1189
|
+
</TimelineGroupRenderBoundary>
|
|
998
1190
|
</div>
|
|
999
1191
|
);
|
|
1000
1192
|
})}
|
|
@@ -1242,6 +1434,75 @@ function cancelFrame(id: number): void {
|
|
|
1242
1434
|
window.clearTimeout(id);
|
|
1243
1435
|
}
|
|
1244
1436
|
|
|
1437
|
+
type TimelineGroupRenderBoundaryProps = {
|
|
1438
|
+
children: ReactNode;
|
|
1439
|
+
resetKeys: readonly unknown[];
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
type TimelineGroupRenderBoundaryState = {
|
|
1443
|
+
failed: boolean;
|
|
1444
|
+
resetKeys: readonly unknown[];
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
function timelineRenderResetKeysChanged(
|
|
1448
|
+
previous: readonly unknown[],
|
|
1449
|
+
next: readonly unknown[],
|
|
1450
|
+
): boolean {
|
|
1451
|
+
return (
|
|
1452
|
+
previous.length !== next.length || previous.some((key, index) => !Object.is(key, next[index]))
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* A malformed historical payload or consumer renderer must not take down the
|
|
1458
|
+
* entire conversation. Keep the boundary outside the row component so React
|
|
1459
|
+
* can replace an invalid element type (error #130) with a bounded fallback.
|
|
1460
|
+
*/
|
|
1461
|
+
class TimelineGroupRenderBoundary extends Component<
|
|
1462
|
+
TimelineGroupRenderBoundaryProps,
|
|
1463
|
+
TimelineGroupRenderBoundaryState
|
|
1464
|
+
> {
|
|
1465
|
+
state: TimelineGroupRenderBoundaryState = {
|
|
1466
|
+
failed: false,
|
|
1467
|
+
resetKeys: this.props.resetKeys,
|
|
1468
|
+
};
|
|
1469
|
+
|
|
1470
|
+
static getDerivedStateFromError(): Partial<TimelineGroupRenderBoundaryState> {
|
|
1471
|
+
return { failed: true };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
static getDerivedStateFromProps(
|
|
1475
|
+
props: TimelineGroupRenderBoundaryProps,
|
|
1476
|
+
state: TimelineGroupRenderBoundaryState,
|
|
1477
|
+
): Partial<TimelineGroupRenderBoundaryState> | null {
|
|
1478
|
+
if (timelineRenderResetKeysChanged(state.resetKeys, props.resetKeys)) {
|
|
1479
|
+
return { failed: false, resetKeys: props.resetKeys };
|
|
1480
|
+
}
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
render(): ReactNode {
|
|
1485
|
+
if (this.state.failed) {
|
|
1486
|
+
return (
|
|
1487
|
+
<div
|
|
1488
|
+
data-testid="timeline-group-render-error"
|
|
1489
|
+
role="status"
|
|
1490
|
+
className="flex items-start gap-2 rounded-lg border border-og-border bg-og-surface-muted px-3 py-2 text-sm text-og-fg-muted"
|
|
1491
|
+
>
|
|
1492
|
+
<TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
1493
|
+
<div>
|
|
1494
|
+
<p className="font-medium text-og-fg">Timeline item unavailable</p>
|
|
1495
|
+
<p>
|
|
1496
|
+
This item could not be displayed. The rest of the conversation is still available.
|
|
1497
|
+
</p>
|
|
1498
|
+
</div>
|
|
1499
|
+
</div>
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
return this.props.children;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1245
1506
|
// The full loaded window stays mounted, so settled history rows must be cheap
|
|
1246
1507
|
// on every commit: projection reuses group objects for unchanged groups, so
|
|
1247
1508
|
// memo skips them. Live projection creates a new group object, and
|
|
@@ -1308,7 +1569,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1308
1569
|
// Latch live→folded so a top-level shell that was already mounted open can
|
|
1309
1570
|
// start the settle beat without remounting bare rail → wrapper.
|
|
1310
1571
|
const liveActivitySettle = useLiveSettleFold(activityShouldFold && !insideTurn);
|
|
1311
|
-
const turnDefaultOpen =
|
|
1572
|
+
const turnDefaultOpen =
|
|
1573
|
+
!insideTurn &&
|
|
1574
|
+
group.kind === "turn" &&
|
|
1575
|
+
(group.outcome === "failed" || timelineGroupContainsAuthNeeded(group));
|
|
1312
1576
|
// activity-* → turn-* remount: carry resting state so settleFold does not
|
|
1313
1577
|
// re-open a chip the reader already watched collapse.
|
|
1314
1578
|
if (group.kind === "turn" && foldMemory && !insideTurn) {
|
|
@@ -1400,21 +1664,37 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1400
1664
|
// more "N steps" was the redundant double fold).
|
|
1401
1665
|
const nestClusters = foldableActivityClusterCount(group.groups) >= 2;
|
|
1402
1666
|
const turnCopyText = collectTurnCopyText(group.groups, trailingAgentText);
|
|
1403
|
-
const body = group.groups.map((child) =>
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1667
|
+
const body = group.groups.map((child) => {
|
|
1668
|
+
const key = timelineGroupKey(child);
|
|
1669
|
+
return (
|
|
1670
|
+
<TimelineGroupRenderBoundary
|
|
1671
|
+
key={key}
|
|
1672
|
+
resetKeys={[
|
|
1673
|
+
child,
|
|
1674
|
+
renderMessageText,
|
|
1675
|
+
onOpenSession,
|
|
1676
|
+
onMemoryClick,
|
|
1677
|
+
onReconnect,
|
|
1678
|
+
resolveProviderLogo,
|
|
1679
|
+
toolRegistry,
|
|
1680
|
+
turnSummary,
|
|
1681
|
+
]}
|
|
1682
|
+
>
|
|
1683
|
+
<TimelineGroupView
|
|
1684
|
+
group={child}
|
|
1685
|
+
renderMessageText={renderMessageText}
|
|
1686
|
+
onOpenSession={onOpenSession}
|
|
1687
|
+
onMemoryClick={onMemoryClick}
|
|
1688
|
+
onReconnect={onReconnect}
|
|
1689
|
+
resolveProviderLogo={resolveProviderLogo}
|
|
1690
|
+
toolRegistry={toolRegistry}
|
|
1691
|
+
turnSummary={turnSummary}
|
|
1692
|
+
insideTurn
|
|
1693
|
+
nestClusterChips={nestClusters}
|
|
1694
|
+
/>
|
|
1695
|
+
</TimelineGroupRenderBoundary>
|
|
1696
|
+
);
|
|
1697
|
+
});
|
|
1418
1698
|
return (
|
|
1419
1699
|
<TurnSummary
|
|
1420
1700
|
items={activityItems}
|
|
@@ -1501,6 +1781,17 @@ function timelineGroupKey(group: TimelineGroup): string {
|
|
|
1501
1781
|
}
|
|
1502
1782
|
}
|
|
1503
1783
|
|
|
1784
|
+
function timelineGroupContainsAuthNeeded(group: TimelineGroup): boolean {
|
|
1785
|
+
switch (group.kind) {
|
|
1786
|
+
case "item":
|
|
1787
|
+
return group.item.kind === "auth-needed";
|
|
1788
|
+
case "activity":
|
|
1789
|
+
return false;
|
|
1790
|
+
case "turn":
|
|
1791
|
+
return group.groups.some(timelineGroupContainsAuthNeeded);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1504
1795
|
function timelineGroupItemIds(group: TimelineGroup): string[] {
|
|
1505
1796
|
switch (group.kind) {
|
|
1506
1797
|
case "item":
|
|
@@ -1787,6 +2078,22 @@ function compactionSkipSubtitle(reason: string | null): string {
|
|
|
1787
2078
|
}
|
|
1788
2079
|
}
|
|
1789
2080
|
|
|
2081
|
+
/** Hover-reveal clock beside the copy control (sent / finished). */
|
|
2082
|
+
function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
|
|
2083
|
+
return (
|
|
2084
|
+
<time
|
|
2085
|
+
dateTime={occurredAt}
|
|
2086
|
+
className={cn(
|
|
2087
|
+
"shrink-0 tabular-nums text-og-xs text-og-fg-subtle",
|
|
2088
|
+
"opacity-0 transition-opacity duration-150",
|
|
2089
|
+
"group-hover/copy:opacity-100 group-focus-within/copy:opacity-100 pointer-coarse:opacity-70",
|
|
2090
|
+
)}
|
|
2091
|
+
>
|
|
2092
|
+
{formatClockTime(occurredAt)}
|
|
2093
|
+
</time>
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
1790
2097
|
function UserMessageRow({
|
|
1791
2098
|
item,
|
|
1792
2099
|
renderMessageText,
|
|
@@ -1804,6 +2111,7 @@ function UserMessageRow({
|
|
|
1804
2111
|
copyText={item.text}
|
|
1805
2112
|
label="Copy message"
|
|
1806
2113
|
className="w-fit max-w-full min-w-0"
|
|
2114
|
+
trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
|
|
1807
2115
|
>
|
|
1808
2116
|
<div className="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">
|
|
1809
2117
|
{renderMessageText ? (
|
|
@@ -1835,13 +2143,15 @@ function AgentMessageRow({
|
|
|
1835
2143
|
) : (
|
|
1836
2144
|
<Markdown streaming={item.streaming}>{item.text}</Markdown>
|
|
1837
2145
|
);
|
|
1838
|
-
// While streaming, copy is still useful (current text) but keep chrome calm
|
|
2146
|
+
// While streaming, copy is still useful (current text) but keep chrome calm —
|
|
2147
|
+
// stamp only after the message finishes (occurredAt tracks completion).
|
|
1839
2148
|
return (
|
|
1840
2149
|
<CopyHoverFrame
|
|
1841
2150
|
copyText={item.text}
|
|
1842
2151
|
label="Copy message"
|
|
1843
|
-
align="
|
|
2152
|
+
align="start"
|
|
1844
2153
|
className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
|
|
2154
|
+
trailing={item.streaming ? null : <MessageFooterTime occurredAt={item.occurredAt} />}
|
|
1845
2155
|
>
|
|
1846
2156
|
{body}
|
|
1847
2157
|
</CopyHoverFrame>
|
|
@@ -2236,7 +2546,9 @@ function AuthNeededRow({
|
|
|
2236
2546
|
const [failed, setFailed] = useState(false);
|
|
2237
2547
|
const provider = providerLabel(item.providerDomain);
|
|
2238
2548
|
const unavailable =
|
|
2239
|
-
item.reason === "
|
|
2549
|
+
item.reason === "personal_authority_unavailable" ||
|
|
2550
|
+
item.reason === "unsupported_auth" ||
|
|
2551
|
+
item.reason === "resource_scope_unavailable";
|
|
2240
2552
|
const missing = item.reason === "missing_connection";
|
|
2241
2553
|
const actionLabel = missing ? "Connect" : "Reconnect";
|
|
2242
2554
|
|
|
@@ -2390,6 +2702,8 @@ function authReasonLine(reason: AuthNeededItem["reason"]): string {
|
|
|
2390
2702
|
case "expired":
|
|
2391
2703
|
case "refresh_failed":
|
|
2392
2704
|
return "Its access expired.";
|
|
2705
|
+
case "personal_authority_unavailable":
|
|
2706
|
+
return "This automation was not granted access to your personal connection.";
|
|
2393
2707
|
case "unsupported_auth":
|
|
2394
2708
|
return "This connection cannot authenticate the configured tool endpoint.";
|
|
2395
2709
|
case "resource_scope_unavailable":
|