@opengeni/react 0.34.1 → 0.34.3
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-A2WITZTI.js → chunk-4NMH6UJ3.js} +2 -2
- 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-KZRUYCL4.js → chunk-FT2TXNGZ.js} +5 -2
- package/dist/chunk-FT2TXNGZ.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-TCCE5ZU5.js → chunk-MEGI4FS6.js} +38 -9
- package/dist/chunk-MEGI4FS6.js.map +1 -0
- package/dist/{chunk-MS3FBZ6A.js → chunk-PVW56EVR.js} +105 -62
- package/dist/chunk-PVW56EVR.js.map +1 -0
- package/dist/{chunk-L7R5RK6A.js → chunk-QRYJCWGD.js} +1199 -897
- package/dist/chunk-QRYJCWGD.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/index.d.ts +2 -2
- package/dist/index.js +106 -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 +1 -1
- 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 +236 -39
- 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-workspace-capture.ts +6 -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 +50 -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-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-A2WITZTI.js.map → chunk-4NMH6UJ3.js.map} +0 -0
- /package/dist/{chunk-GZ4KR67U.js.map → chunk-ALA3UGWB.js.map} +0 -0
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
type ReactNode,
|
|
33
33
|
} from "react";
|
|
34
34
|
import { cn } from "../lib/cn";
|
|
35
|
-
import { formatRelativeTime, truncate } from "../lib/format";
|
|
35
|
+
import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
|
|
36
36
|
import { prefersReducedMotion } from "../lib/motion";
|
|
37
37
|
import { Markdown } from "./markdown";
|
|
38
38
|
import {
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
tipFollowCancel,
|
|
42
42
|
tipFollowCompensateShrink,
|
|
43
43
|
tipFollowStep,
|
|
44
|
+
supportsScrollEndEvent,
|
|
44
45
|
TIP_FOLLOW_READER_UP_EPS_PX,
|
|
45
46
|
TIP_FOLLOW_SHRINK_EPS_PX,
|
|
46
47
|
type TipFollowState,
|
|
@@ -158,12 +159,16 @@ export type MessageTimelineProps = {
|
|
|
158
159
|
* replaces was the "content is hidden, then pops in in batches" wobble.)
|
|
159
160
|
*
|
|
160
161
|
* Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
|
|
161
|
-
* -
|
|
162
|
+
* - Load/remount: hidden until tip is hard-snapped across a short settle; then
|
|
163
|
+
* reveal. Live tip: DOM grows immediately; the camera eases down (not
|
|
162
164
|
* tip-glued feed-forward — that is the one-line yank).
|
|
163
165
|
* - One continuous follow while hot (faster τ when behind); sleeps when cold.
|
|
164
166
|
* - While pinned, tip-debt from growth/collapse must NEVER unpin — only
|
|
165
|
-
* wheel/keys/pointer-armed scroll-up
|
|
166
|
-
*
|
|
167
|
+
* wheel/keys/pointer-armed scroll-up, or a settled scrollend away from the
|
|
168
|
+
* tip while the tip-follow camera is idle (Vimium / unfocused PageUp).
|
|
169
|
+
* Height shrink compensates scrollTop by Δh (collapse owns motion); tip-ease
|
|
170
|
+
* pauses briefly so the two don't fight. Programmatic camera writes are
|
|
171
|
+
* tagged so their scroll echoes never count as leave.
|
|
167
172
|
* - overflow-anchor off while pinned so the browser cannot instant-correct.
|
|
168
173
|
* - Scrolled up → history prepends restore via the retained group anchor
|
|
169
174
|
* (offsetTop delta); loadOlder can truncate the tip, so scrollHeight delta
|
|
@@ -288,9 +293,10 @@ export function MessageTimeline({
|
|
|
288
293
|
const olderLoadGateRef = useRef<"armed" | "cooling">("armed");
|
|
289
294
|
const resizeFollowRafRef = useRef<number | null>(null);
|
|
290
295
|
const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
|
|
291
|
-
// Content stays invisible until
|
|
292
|
-
//
|
|
293
|
-
//
|
|
296
|
+
// Content stays invisible until the tip is hard-parked across a short
|
|
297
|
+
// post-commit settle (two rAFs). That absorbs sync late layout while hidden
|
|
298
|
+
// so load/remount does not ease into the tip — live tip-follow is unchanged
|
|
299
|
+
// once revealed. A flash of the window's TOP is still structurally impossible.
|
|
294
300
|
const [revealed, setRevealed] = useState(false);
|
|
295
301
|
// Mirror `pinned` into a ref, written ONLY by applyPinned, so the
|
|
296
302
|
// ResizeObserver rAF (a stable closure) reads the live value and a snap can
|
|
@@ -325,11 +331,24 @@ export function MessageTimeline({
|
|
|
325
331
|
const lastScrollHeightRef = useRef(0);
|
|
326
332
|
const lastClientHeightRef = useRef(0);
|
|
327
333
|
/**
|
|
328
|
-
* Armed by pointerdown on the scroller.
|
|
329
|
-
* while armed
|
|
330
|
-
*
|
|
334
|
+
* Armed by pointerdown on the scroller. Immediate geometric scroll-up unpins
|
|
335
|
+
* while armed (scrollbar / touch drag). Wheel/keys unpin directly. Extension
|
|
336
|
+
* jumps (Vimium) settle via scrollend while the camera is idle.
|
|
331
337
|
*/
|
|
332
338
|
const readerIntentArmRef = useRef(false);
|
|
339
|
+
/**
|
|
340
|
+
* Count of camera/snap scrollTop writes whose scroll echoes are not yet
|
|
341
|
+
* consumed. A boolean was wrong when the browser coalesced two writes into
|
|
342
|
+
* one scroll event (or fired two) — use a count, and clear to 0 on echo.
|
|
343
|
+
*/
|
|
344
|
+
const programmaticScrollRef = useRef(0);
|
|
345
|
+
/**
|
|
346
|
+
* Unarmed scroll-away observed; waiting for scrollend (or rAF fallback).
|
|
347
|
+
* Blocks layout tip-follow so a stream token cannot yank before leave settles.
|
|
348
|
+
*/
|
|
349
|
+
const pendingReaderLeaveRef = useRef(false);
|
|
350
|
+
/** Fallback leave check when `scrollend` is missing (one rAF, not a timer). */
|
|
351
|
+
const leaveFallbackRafRef = useRef<number | null>(null);
|
|
333
352
|
// Resting fold state per durable group id (see fold-memory.ts). Outlives the
|
|
334
353
|
// deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
|
|
335
354
|
// that already settled closed — or that the reader closed — never reopens.
|
|
@@ -369,6 +388,27 @@ export function MessageTimeline({
|
|
|
369
388
|
lastClientHeightRef.current = node.clientHeight;
|
|
370
389
|
}, []);
|
|
371
390
|
|
|
391
|
+
const writeScrollTop = useCallback((node: HTMLElement, top: number) => {
|
|
392
|
+
const next = Math.max(0, top);
|
|
393
|
+
if (node.scrollTop === next) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
programmaticScrollRef.current += 1;
|
|
397
|
+
node.scrollTop = next;
|
|
398
|
+
}, []);
|
|
399
|
+
|
|
400
|
+
const cancelLeaveFallback = useCallback(() => {
|
|
401
|
+
if (leaveFallbackRafRef.current !== null) {
|
|
402
|
+
cancelFrame(leaveFallbackRafRef.current);
|
|
403
|
+
leaveFallbackRafRef.current = null;
|
|
404
|
+
}
|
|
405
|
+
}, []);
|
|
406
|
+
|
|
407
|
+
const clearPendingReaderLeave = useCallback(() => {
|
|
408
|
+
pendingReaderLeaveRef.current = false;
|
|
409
|
+
cancelLeaveFallback();
|
|
410
|
+
}, [cancelLeaveFallback]);
|
|
411
|
+
|
|
372
412
|
const stopFollow = useCallback(() => {
|
|
373
413
|
followRef.current = tipFollowCancel(followRef.current);
|
|
374
414
|
if (followFrameRef.current !== null) {
|
|
@@ -377,7 +417,7 @@ export function MessageTimeline({
|
|
|
377
417
|
}
|
|
378
418
|
}, []);
|
|
379
419
|
|
|
380
|
-
/** Reader left the tip — wheel, keyboard,
|
|
420
|
+
/** Reader left the tip — wheel, keyboard, pointer-armed scroll-up, or scrollend. */
|
|
381
421
|
const releasePinFromReader = useCallback(
|
|
382
422
|
(node?: HTMLElement | null) => {
|
|
383
423
|
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
@@ -388,6 +428,7 @@ export function MessageTimeline({
|
|
|
388
428
|
return;
|
|
389
429
|
}
|
|
390
430
|
readerIntentArmRef.current = false;
|
|
431
|
+
clearPendingReaderLeave();
|
|
391
432
|
stopFollow();
|
|
392
433
|
applyPinned(false);
|
|
393
434
|
if (wantPinRef.current) {
|
|
@@ -398,9 +439,51 @@ export function MessageTimeline({
|
|
|
398
439
|
setOlderPrefetchArmed(true);
|
|
399
440
|
}
|
|
400
441
|
},
|
|
401
|
-
[autoFollow, applyPinned, stopFollow],
|
|
442
|
+
[autoFollow, applyPinned, clearPendingReaderLeave, stopFollow],
|
|
402
443
|
);
|
|
403
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Settled away from the tip while the camera is idle — Vimium / unfocused
|
|
447
|
+
* PageUp. Folds are recovered by layout tip-follow before this fires at tip.
|
|
448
|
+
*/
|
|
449
|
+
const releasePinAfterScrollSettled = useCallback(
|
|
450
|
+
(node: HTMLElement) => {
|
|
451
|
+
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (programmaticScrollRef.current > 0) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (followRef.current.running || followFrameRef.current !== null) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (isNearBottom(node) || maxScrollOf(node) <= 1) {
|
|
461
|
+
clearPendingReaderLeave();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
releasePinFromReader(node);
|
|
465
|
+
if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
466
|
+
olderLoadGateRef.current = "armed";
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
[autoFollow, clearPendingReaderLeave, releasePinFromReader],
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const scheduleLeaveFallback = useCallback(() => {
|
|
473
|
+
// Prefer scrollend when the engine supports it.
|
|
474
|
+
if (supportsScrollEndEvent()) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
cancelLeaveFallback();
|
|
478
|
+
leaveFallbackRafRef.current = requestFrame(() => {
|
|
479
|
+
leaveFallbackRafRef.current = null;
|
|
480
|
+
const current = scrollRef.current;
|
|
481
|
+
if (current) {
|
|
482
|
+
releasePinAfterScrollSettled(current);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}, [cancelLeaveFallback, releasePinAfterScrollSettled]);
|
|
486
|
+
|
|
404
487
|
const onWheel = (event: {
|
|
405
488
|
deltaY: number;
|
|
406
489
|
deltaX: number;
|
|
@@ -455,14 +538,15 @@ export function MessageTimeline({
|
|
|
455
538
|
const snapToBottom = useCallback(
|
|
456
539
|
(node: HTMLElement) => {
|
|
457
540
|
stopFollow();
|
|
458
|
-
|
|
541
|
+
cancelLeaveFallback();
|
|
542
|
+
writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
|
|
459
543
|
syncScrollBaseline(node);
|
|
460
544
|
followRef.current = {
|
|
461
545
|
...followRef.current,
|
|
462
546
|
lastHeight: node.scrollHeight,
|
|
463
547
|
};
|
|
464
548
|
},
|
|
465
|
-
[stopFollow, syncScrollBaseline],
|
|
549
|
+
[cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
|
|
466
550
|
);
|
|
467
551
|
|
|
468
552
|
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
|
|
@@ -472,6 +556,12 @@ export function MessageTimeline({
|
|
|
472
556
|
stopFollow();
|
|
473
557
|
return;
|
|
474
558
|
}
|
|
559
|
+
// Reader/extension leave in flight — do not yank back before scrollend.
|
|
560
|
+
if (pendingReaderLeaveRef.current) {
|
|
561
|
+
stopFollow();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
cancelLeaveFallback();
|
|
475
565
|
// Prefer the rAF timestamp so ease integrates against vsync (and tests
|
|
476
566
|
// can advance a synthetic clock via requestAnimationFrame callbacks).
|
|
477
567
|
const now =
|
|
@@ -493,9 +583,7 @@ export function MessageTimeline({
|
|
|
493
583
|
node.scrollHeight,
|
|
494
584
|
node.clientHeight,
|
|
495
585
|
);
|
|
496
|
-
|
|
497
|
-
node.scrollTop = nextTop;
|
|
498
|
-
}
|
|
586
|
+
writeScrollTop(node, nextTop);
|
|
499
587
|
syncScrollBaseline(node);
|
|
500
588
|
followRef.current = {
|
|
501
589
|
...followRef.current,
|
|
@@ -531,11 +619,10 @@ export function MessageTimeline({
|
|
|
531
619
|
revealed: revealedRef.current,
|
|
532
620
|
});
|
|
533
621
|
followRef.current = result.state;
|
|
534
|
-
|
|
535
|
-
node.scrollTop = result.scrollTop;
|
|
536
|
-
}
|
|
622
|
+
writeScrollTop(node, result.scrollTop);
|
|
537
623
|
syncScrollBaseline(node);
|
|
538
624
|
if (result.state.running) {
|
|
625
|
+
cancelLeaveFallback();
|
|
539
626
|
if (followFrameRef.current === null) {
|
|
540
627
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
541
628
|
followFrameRef.current = null;
|
|
@@ -550,11 +637,12 @@ export function MessageTimeline({
|
|
|
550
637
|
followFrameRef.current = null;
|
|
551
638
|
}
|
|
552
639
|
},
|
|
553
|
-
[stopFollow, syncScrollBaseline],
|
|
640
|
+
[cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
|
|
554
641
|
);
|
|
555
642
|
driveFollowRef.current = driveFollow;
|
|
556
643
|
|
|
557
644
|
useEffect(() => stopFollow, [stopFollow]);
|
|
645
|
+
useEffect(() => () => cancelLeaveFallback(), [cancelLeaveFallback]);
|
|
558
646
|
|
|
559
647
|
// The single post-commit scroll authority. Runs after EVERY commit (no dep
|
|
560
648
|
// list): any commit may change content height, and the decision is cheap.
|
|
@@ -578,7 +666,7 @@ export function MessageTimeline({
|
|
|
578
666
|
// prepend correction (it would shift the reader away from the top).
|
|
579
667
|
pendingJumpToStartRef.current = false;
|
|
580
668
|
stopFollow();
|
|
581
|
-
node
|
|
669
|
+
writeScrollTop(node, 0);
|
|
582
670
|
} else if (wantPinRef.current && !hasNewer) {
|
|
583
671
|
// Jump-to-latest was pressed on a history window and the tip window is
|
|
584
672
|
// in THIS commit — consume pre-paint so the first tip frame is already
|
|
@@ -589,8 +677,19 @@ export function MessageTimeline({
|
|
|
589
677
|
snapToBottom(node);
|
|
590
678
|
}
|
|
591
679
|
} else if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
592
|
-
//
|
|
593
|
-
|
|
680
|
+
// Load/remount (still hidden): hard-park. Live tip after reveal: ease.
|
|
681
|
+
// Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
|
|
682
|
+
// Flat/shrink commits (fold) still recover — height did not grow under us.
|
|
683
|
+
if (!revealedRef.current) {
|
|
684
|
+
snapToBottom(node);
|
|
685
|
+
} else if (pendingReaderLeaveRef.current) {
|
|
686
|
+
if (node.scrollHeight <= lastScrollHeightRef.current) {
|
|
687
|
+
clearPendingReaderLeave();
|
|
688
|
+
driveFollow(node);
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
driveFollow(node);
|
|
692
|
+
}
|
|
594
693
|
} else if (prepended) {
|
|
595
694
|
// Keep the reader on the same retained rows. Prefer the offsetTop delta
|
|
596
695
|
// of the group that still holds the previous first item — that stays
|
|
@@ -622,7 +721,7 @@ export function MessageTimeline({
|
|
|
622
721
|
if (delta !== null) {
|
|
623
722
|
const expected = previousScrollTop + delta;
|
|
624
723
|
if (Math.abs(node.scrollTop - expected) > 2) {
|
|
625
|
-
node
|
|
724
|
+
writeScrollTop(node, expected);
|
|
626
725
|
}
|
|
627
726
|
}
|
|
628
727
|
}
|
|
@@ -661,11 +760,46 @@ export function MessageTimeline({
|
|
|
661
760
|
}
|
|
662
761
|
groupOffsetByKeyRef.current = nextOffsetByKey;
|
|
663
762
|
}
|
|
664
|
-
if (!revealed && groups.length > 0) {
|
|
665
|
-
setRevealed(true);
|
|
666
|
-
}
|
|
667
763
|
});
|
|
668
764
|
|
|
765
|
+
// First paint / session remount: keep the scroller hidden, snap to tip for
|
|
766
|
+
// two animation frames (late sync layout), then reveal. Does not change the
|
|
767
|
+
// tip-follow ease law used once `revealed` is true.
|
|
768
|
+
useLayoutEffect(() => {
|
|
769
|
+
if (revealed || allGroups.length === 0) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
let cancelled = false;
|
|
773
|
+
let frame2 = 0;
|
|
774
|
+
const park = () => {
|
|
775
|
+
const node = scrollRef.current;
|
|
776
|
+
if (node && autoFollow && pinnedRef.current && !hasNewerRef.current) {
|
|
777
|
+
snapToBottom(node);
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
park();
|
|
781
|
+
const frame1 = requestFrame(() => {
|
|
782
|
+
if (cancelled) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
park();
|
|
786
|
+
frame2 = requestFrame(() => {
|
|
787
|
+
if (cancelled) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
park();
|
|
791
|
+
setRevealed(true);
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
return () => {
|
|
795
|
+
cancelled = true;
|
|
796
|
+
cancelFrame(frame1);
|
|
797
|
+
if (frame2 !== 0) {
|
|
798
|
+
cancelFrame(frame2);
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
}, [revealed, allGroups.length, autoFollow, snapToBottom]);
|
|
802
|
+
|
|
669
803
|
// A cleared timeline (stream identity change) re-arms the reveal + prefetch
|
|
670
804
|
// gate and returns to bottom-follow for the next session's first paint.
|
|
671
805
|
useLayoutEffect(() => {
|
|
@@ -792,9 +926,15 @@ export function MessageTimeline({
|
|
|
792
926
|
if (!current) {
|
|
793
927
|
return;
|
|
794
928
|
}
|
|
795
|
-
if (autoFollow
|
|
796
|
-
|
|
929
|
+
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
// Still unveiling the first tip frame: hard-park (no ease settle).
|
|
933
|
+
if (!revealedRef.current) {
|
|
934
|
+
snapToBottom(current);
|
|
935
|
+
return;
|
|
797
936
|
}
|
|
937
|
+
driveFollow(current);
|
|
798
938
|
});
|
|
799
939
|
});
|
|
800
940
|
observer.observe(inner);
|
|
@@ -808,7 +948,7 @@ export function MessageTimeline({
|
|
|
808
948
|
resizeFollowRafRef.current = null;
|
|
809
949
|
}
|
|
810
950
|
};
|
|
811
|
-
}, [autoFollow, driveFollow]);
|
|
951
|
+
}, [autoFollow, driveFollow, snapToBottom]);
|
|
812
952
|
|
|
813
953
|
// Entering a non-tip history window: drop any live pin so the page bottom
|
|
814
954
|
// cannot re-stick follow across loadNewer. Leaving it (the tip window
|
|
@@ -838,9 +978,9 @@ export function MessageTimeline({
|
|
|
838
978
|
}
|
|
839
979
|
}, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
|
|
840
980
|
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
981
|
+
// Pinned: layout/camera recover tip debt; wheel/keys/pointer-arm unpin
|
|
982
|
+
// immediately; extension jumps settle via scrollend (or one-rAF fallback).
|
|
983
|
+
// Do not tip-follow-yank an in-flight unarmed scroll-away — that ate Vimium.
|
|
844
984
|
const onScroll = () => {
|
|
845
985
|
const node = scrollRef.current;
|
|
846
986
|
if (!node) {
|
|
@@ -857,17 +997,34 @@ export function MessageTimeline({
|
|
|
857
997
|
const heightShrunk =
|
|
858
998
|
followRef.current.lastHeight > 0 &&
|
|
859
999
|
nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
|
|
1000
|
+
// Consume all pending camera-write echoes (browsers may coalesce writes).
|
|
1001
|
+
const programmatic = programmaticScrollRef.current > 0;
|
|
1002
|
+
if (programmatic) {
|
|
1003
|
+
programmaticScrollRef.current = 0;
|
|
1004
|
+
}
|
|
860
1005
|
|
|
861
1006
|
if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
862
1007
|
// Fold / composer shrink: compensate before baseline sync so driveFollow
|
|
863
1008
|
// still sees the pre-shrink scrollTop (avoid double-subtract after clamp).
|
|
864
1009
|
if (heightShrunk || maxFell) {
|
|
865
1010
|
readerIntentArmRef.current = false;
|
|
1011
|
+
clearPendingReaderLeave();
|
|
866
1012
|
driveFollow(node);
|
|
867
1013
|
return;
|
|
868
1014
|
}
|
|
1015
|
+
if (programmatic) {
|
|
1016
|
+
syncScrollBaseline(node);
|
|
1017
|
+
followRef.current = {
|
|
1018
|
+
...followRef.current,
|
|
1019
|
+
lastHeight: nextHeight,
|
|
1020
|
+
};
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
869
1023
|
syncScrollBaseline(node);
|
|
870
1024
|
const nearBottomPinned = isNearBottom(node);
|
|
1025
|
+
if (nearBottomPinned) {
|
|
1026
|
+
clearPendingReaderLeave();
|
|
1027
|
+
}
|
|
871
1028
|
// Pointer-dragged scroll-up away from tip. Layout churn never arms this.
|
|
872
1029
|
if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
|
|
873
1030
|
readerIntentArmRef.current = false;
|
|
@@ -877,9 +1034,15 @@ export function MessageTimeline({
|
|
|
877
1034
|
}
|
|
878
1035
|
return;
|
|
879
1036
|
}
|
|
880
|
-
//
|
|
881
|
-
|
|
882
|
-
|
|
1037
|
+
// Tip grew under a still viewport (no reader-up): ease back to the tip.
|
|
1038
|
+
// Reader/extension scroll-up in progress: do not yank — scrollend decides.
|
|
1039
|
+
if (!nearBottomPinned && readerUp <= TIP_FOLLOW_READER_UP_EPS_PX) {
|
|
1040
|
+
if (!pendingReaderLeaveRef.current) {
|
|
1041
|
+
driveFollow(node);
|
|
1042
|
+
}
|
|
1043
|
+
} else if (!nearBottomPinned && readerUp > TIP_FOLLOW_READER_UP_EPS_PX) {
|
|
1044
|
+
pendingReaderLeaveRef.current = true;
|
|
1045
|
+
scheduleLeaveFallback();
|
|
883
1046
|
} else {
|
|
884
1047
|
followRef.current = {
|
|
885
1048
|
...followRef.current,
|
|
@@ -922,6 +1085,20 @@ export function MessageTimeline({
|
|
|
922
1085
|
}
|
|
923
1086
|
};
|
|
924
1087
|
|
|
1088
|
+
const onScrollEnd = () => {
|
|
1089
|
+
const node = scrollRef.current;
|
|
1090
|
+
if (!node) {
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
cancelLeaveFallback();
|
|
1094
|
+
if (programmaticScrollRef.current > 0) {
|
|
1095
|
+
programmaticScrollRef.current = 0;
|
|
1096
|
+
syncScrollBaseline(node);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
releasePinAfterScrollSettled(node);
|
|
1100
|
+
};
|
|
1101
|
+
|
|
925
1102
|
return (
|
|
926
1103
|
<LightboxProvider>
|
|
927
1104
|
<FoldMemoryProvider value={foldMemoryRef.current}>
|
|
@@ -935,6 +1112,7 @@ export function MessageTimeline({
|
|
|
935
1112
|
ref={scrollRef}
|
|
936
1113
|
tabIndex={-1}
|
|
937
1114
|
onScroll={onScroll}
|
|
1115
|
+
onScrollEnd={onScrollEnd}
|
|
938
1116
|
onWheel={onWheel}
|
|
939
1117
|
onPointerDown={onPointerDown}
|
|
940
1118
|
onKeyDown={onKeyDown}
|
|
@@ -1787,6 +1965,22 @@ function compactionSkipSubtitle(reason: string | null): string {
|
|
|
1787
1965
|
}
|
|
1788
1966
|
}
|
|
1789
1967
|
|
|
1968
|
+
/** Hover-reveal clock beside the copy control (sent / finished). */
|
|
1969
|
+
function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
|
|
1970
|
+
return (
|
|
1971
|
+
<time
|
|
1972
|
+
dateTime={occurredAt}
|
|
1973
|
+
className={cn(
|
|
1974
|
+
"shrink-0 tabular-nums text-og-xs text-og-fg-subtle",
|
|
1975
|
+
"opacity-0 transition-opacity duration-150",
|
|
1976
|
+
"group-hover/copy:opacity-100 group-focus-within/copy:opacity-100 pointer-coarse:opacity-70",
|
|
1977
|
+
)}
|
|
1978
|
+
>
|
|
1979
|
+
{formatClockTime(occurredAt)}
|
|
1980
|
+
</time>
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1790
1984
|
function UserMessageRow({
|
|
1791
1985
|
item,
|
|
1792
1986
|
renderMessageText,
|
|
@@ -1804,6 +1998,7 @@ function UserMessageRow({
|
|
|
1804
1998
|
copyText={item.text}
|
|
1805
1999
|
label="Copy message"
|
|
1806
2000
|
className="w-fit max-w-full min-w-0"
|
|
2001
|
+
trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
|
|
1807
2002
|
>
|
|
1808
2003
|
<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
2004
|
{renderMessageText ? (
|
|
@@ -1835,13 +2030,15 @@ function AgentMessageRow({
|
|
|
1835
2030
|
) : (
|
|
1836
2031
|
<Markdown streaming={item.streaming}>{item.text}</Markdown>
|
|
1837
2032
|
);
|
|
1838
|
-
// While streaming, copy is still useful (current text) but keep chrome calm
|
|
2033
|
+
// While streaming, copy is still useful (current text) but keep chrome calm —
|
|
2034
|
+
// stamp only after the message finishes (occurredAt tracks completion).
|
|
1839
2035
|
return (
|
|
1840
2036
|
<CopyHoverFrame
|
|
1841
2037
|
copyText={item.text}
|
|
1842
2038
|
label="Copy message"
|
|
1843
|
-
align="
|
|
2039
|
+
align="start"
|
|
1844
2040
|
className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
|
|
2041
|
+
trailing={item.streaming ? null : <MessageFooterTime occurredAt={item.occurredAt} />}
|
|
1845
2042
|
>
|
|
1846
2043
|
{body}
|
|
1847
2044
|
</CopyHoverFrame>
|
|
@@ -81,6 +81,21 @@ export const TIP_FOLLOW_VELOCITY_ARM_DEBT_PX = 72;
|
|
|
81
81
|
export const TIP_FOLLOW_VELOCITY_DECAY_MS = _t(180);
|
|
82
82
|
/** Reader-up pixels above clamp budget that count as leaving the tip. */
|
|
83
83
|
export const TIP_FOLLOW_READER_UP_EPS_PX = 2;
|
|
84
|
+
|
|
85
|
+
/** Test-only override for scrollend feature detection (`null` = probe DOM). */
|
|
86
|
+
let scrollEndSupportOverride: boolean | null = null;
|
|
87
|
+
|
|
88
|
+
/** @internal */ export function setScrollEndSupportForTests(value: boolean | null): void {
|
|
89
|
+
scrollEndSupportOverride = value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True when the engine exposes element `scrollend` (prefer over rAF leave). */
|
|
93
|
+
export function supportsScrollEndEvent(): boolean {
|
|
94
|
+
if (scrollEndSupportOverride !== null) {
|
|
95
|
+
return scrollEndSupportOverride;
|
|
96
|
+
}
|
|
97
|
+
return typeof HTMLElement !== "undefined" && "onscrollend" in HTMLElement.prototype;
|
|
98
|
+
}
|
|
84
99
|
/** @deprecated Shrink lock removed; kept so old imports do not break. */
|
|
85
100
|
export const TIP_FOLLOW_SHRINK_LOCK_MS = 0;
|
|
86
101
|
/** @deprecated Absorb thresholds removed — growth track is continuous. */
|
package/src/composer.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `import * as Composer from "@opengeni/react/composer"`.
|
|
5
5
|
*/
|
|
6
|
+
export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./workstream-control-event";
|
|
6
7
|
export {
|
|
7
8
|
Actions,
|
|
8
9
|
AttachButton,
|
|
@@ -16,7 +17,6 @@ export {
|
|
|
16
17
|
Hint,
|
|
17
18
|
Input,
|
|
18
19
|
ModelPicker,
|
|
19
|
-
OPEN_WORKSTREAM_CONTROL_EVENT,
|
|
20
20
|
PauseButton,
|
|
21
21
|
PausedState,
|
|
22
22
|
RestoredResources,
|
|
@@ -420,7 +420,13 @@ export function useComposer(
|
|
|
420
420
|
localSignatureAtStart === null
|
|
421
421
|
? localAtStart !== 0
|
|
422
422
|
: localSignatureAtStart !== lastSavedSignature.current;
|
|
423
|
-
|
|
423
|
+
// Only blank the picker on first hydrate / hard reload. Reconcile and
|
|
424
|
+
// event-triggered soft reloads (loadOlder SSE reconnect) must not flicker
|
|
425
|
+
// draftLoading — stale-while-revalidate keeps the settled UI mounted.
|
|
426
|
+
const showLoading = replaceLocal || draftRef.current === null;
|
|
427
|
+
if (showLoading) {
|
|
428
|
+
setDraftLoading(true);
|
|
429
|
+
}
|
|
424
430
|
try {
|
|
425
431
|
const fetched = await client.getComposerDraft(workspaceId, sessionId);
|
|
426
432
|
if (
|
|
@@ -315,6 +315,12 @@ export function useWorkspaceCapture(
|
|
|
315
315
|
if (!enabled) return;
|
|
316
316
|
if (visibleAnnouncedRevision === null) return;
|
|
317
317
|
if (loadedRevision !== null && visibleAnnouncedRevision <= loadedRevision) return;
|
|
318
|
+
// The mount request is already resolving the latest revision. Do not abort
|
|
319
|
+
// and replace that exact identity-equivalent read merely because the event
|
|
320
|
+
// tail announced a revision before the response arrived. When the request
|
|
321
|
+
// settles, its state update re-runs this effect and a genuinely newer
|
|
322
|
+
// announcement still refreshes immediately.
|
|
323
|
+
if (requestAbortRef.current) return;
|
|
318
324
|
// A newer capture exists than the one we hold (or we hold none) — pull it.
|
|
319
325
|
void refresh();
|
|
320
326
|
}, [enabled, visibleAnnouncedRevision, loadedRevision, refresh]);
|
package/src/index.ts
CHANGED
|
@@ -100,7 +100,7 @@ export type {
|
|
|
100
100
|
SessionChromeSignalTone,
|
|
101
101
|
} from "./components/session-chrome";
|
|
102
102
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip";
|
|
103
|
-
export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./
|
|
103
|
+
export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./workstream-control-event";
|
|
104
104
|
export { useGoal, isGoalEvent } from "./hooks/use-goal";
|
|
105
105
|
export type { UseGoalOptions, UseGoalResult } from "./hooks/use-goal";
|
|
106
106
|
export { useSessionControl } from "./hooks/use-session-control";
|
|
@@ -486,6 +486,7 @@ export { cn } from "./lib/cn";
|
|
|
486
486
|
export {
|
|
487
487
|
CREDIT_EXHAUSTION_MESSAGE,
|
|
488
488
|
formatBytes,
|
|
489
|
+
formatClockTime,
|
|
489
490
|
formatRelativeTime,
|
|
490
491
|
humanizeFailureReason,
|
|
491
492
|
isCreditExhaustion,
|
package/src/lib/format.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { OpenGeniApiError } from "@opengeni/sdk";
|
|
2
2
|
|
|
3
|
+
/** Local finished-at stamp for message footers: "Aug 2, 3:42 PM" / locale-equivalent. */
|
|
4
|
+
export function formatClockTime(iso: string): string {
|
|
5
|
+
const then = new Date(iso);
|
|
6
|
+
if (Number.isNaN(then.getTime())) {
|
|
7
|
+
return "";
|
|
8
|
+
}
|
|
9
|
+
return then.toLocaleString(undefined, {
|
|
10
|
+
month: "short",
|
|
11
|
+
day: "numeric",
|
|
12
|
+
hour: "numeric",
|
|
13
|
+
minute: "2-digit",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
/** Compact relative time: "now", "42s", "7m", "3h", "2d", then a date. */
|
|
4
18
|
export function formatRelativeTime(iso: string, now: Date = new Date()): string {
|
|
5
19
|
const then = new Date(iso).getTime();
|
package/src/timeline/index.ts
CHANGED
package/src/timeline/parsers.ts
CHANGED
|
@@ -227,6 +227,19 @@ export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
|
|
|
227
227
|
return r.operation ? [r.operation] : [];
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/** Ops from provider `raw`, or from function-tool arguments when raw is empty. */
|
|
231
|
+
export function applyPatchOpsFromToolItem(item: {
|
|
232
|
+
raw: unknown;
|
|
233
|
+
arguments: unknown;
|
|
234
|
+
}): ApplyPatchOperation[] {
|
|
235
|
+
const fromRaw = applyPatchOps(item.raw);
|
|
236
|
+
if (fromRaw.length > 0) {
|
|
237
|
+
return fromRaw;
|
|
238
|
+
}
|
|
239
|
+
const args = parseToolArgs(item.arguments);
|
|
240
|
+
return applyPatchOps(args);
|
|
241
|
+
}
|
|
242
|
+
|
|
230
243
|
/**
|
|
231
244
|
* True when a tool item is an `apply_patch_call` — by its provider-native
|
|
232
245
|
* `raw.type` (the live-wire source of truth) or by tool `name` (first-party
|
|
@@ -235,7 +248,11 @@ export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
|
|
|
235
248
|
export function isApplyPatch(item: { name: string; raw: unknown }): boolean {
|
|
236
249
|
const type =
|
|
237
250
|
item.raw && typeof item.raw === "object" ? (item.raw as { type?: unknown }).type : undefined;
|
|
238
|
-
|
|
251
|
+
if (type === "apply_patch_call") {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
const name = item.name;
|
|
255
|
+
return name === "apply_patch_call" || name === "apply_patch" || name.endsWith("__apply_patch");
|
|
239
256
|
}
|
|
240
257
|
|
|
241
258
|
/* --- secret redaction ------------------------------------------------------- */
|