@opengeni/react 0.41.0 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/{chunk-SJKT4TKW.js → chunk-23EJ676W.js} +3 -1
  2. package/dist/chunk-23EJ676W.js.map +1 -0
  3. package/dist/{chunk-OMCFRWHL.js → chunk-KC27K42G.js} +1443 -101
  4. package/dist/chunk-KC27K42G.js.map +1 -0
  5. package/dist/{chunk-KR2SK5GJ.js → chunk-KG5F2OKE.js} +260 -93
  6. package/dist/chunk-KG5F2OKE.js.map +1 -0
  7. package/dist/{chunk-4IJCL7YO.js → chunk-LWR4MXSS.js} +4 -1
  8. package/dist/chunk-LWR4MXSS.js.map +1 -0
  9. package/dist/{chunk-JALF5FI3.js → chunk-SRFUT2ZU.js} +2 -2
  10. package/dist/{chunk-WZT5G5OR.js → chunk-U6K24XQD.js} +5 -4
  11. package/dist/{chunk-WZT5G5OR.js.map → chunk-U6K24XQD.js.map} +1 -1
  12. package/dist/components/chat-composer.d.ts +5 -1
  13. package/dist/components/composer-transcription-control.d.ts +16 -1
  14. package/dist/components/composer.d.ts +6 -4
  15. package/dist/components/session-chrome.d.ts +1 -1
  16. package/dist/composer.d.ts +3 -1
  17. package/dist/composer.js +29 -3
  18. package/dist/hooks/use-voice-input.d.ts +25 -4
  19. package/dist/index.d.ts +4 -2
  20. package/dist/index.js +95 -20
  21. package/dist/index.js.map +1 -1
  22. package/dist/model-policy.d.ts +2 -0
  23. package/dist/model-policy.js +1 -1
  24. package/dist/realtime/realtime-control.d.ts +29 -1
  25. package/dist/realtime.d.ts +1 -1
  26. package/dist/realtime.js +269 -151
  27. package/dist/realtime.js.map +1 -1
  28. package/dist/session-ui.js +2 -2
  29. package/dist/session.js +2 -2
  30. package/dist/timeline/index.d.ts +1 -1
  31. package/dist/timeline/parsers.d.ts +13 -8
  32. package/dist/voice-recording-owner.d.ts +12 -0
  33. package/dist/voice-recording-store.d.ts +124 -0
  34. package/package.json +2 -2
  35. package/src/components/chat-composer.tsx +58 -19
  36. package/src/components/composer-transcription-control.tsx +258 -117
  37. package/src/components/composer.tsx +78 -14
  38. package/src/components/model-policy-picker.tsx +7 -2
  39. package/src/components/session-chrome.tsx +89 -78
  40. package/src/composer.ts +30 -1
  41. package/src/hooks/use-voice-input.ts +893 -67
  42. package/src/index.ts +32 -1
  43. package/src/model-policy.ts +4 -0
  44. package/src/realtime/realtime-control.tsx +307 -137
  45. package/src/realtime.ts +1 -0
  46. package/src/timeline/index.ts +2 -0
  47. package/src/timeline/parsers.ts +201 -19
  48. package/src/timeline/projection.ts +11 -0
  49. package/src/timeline/tool-renderers.tsx +7 -5
  50. package/src/timeline/turn-summary.tsx +7 -2
  51. package/src/voice-recording-owner.ts +251 -0
  52. package/src/voice-recording-store.ts +528 -0
  53. package/styles/tokens.css +8 -5
  54. package/dist/chunk-4IJCL7YO.js.map +0 -1
  55. package/dist/chunk-KR2SK5GJ.js.map +0 -1
  56. package/dist/chunk-OMCFRWHL.js.map +0 -1
  57. package/dist/chunk-SJKT4TKW.js.map +0 -1
  58. /package/dist/{chunk-JALF5FI3.js.map → chunk-SRFUT2ZU.js.map} +0 -0
@@ -241,25 +241,84 @@ export type UseChatComposerControllerOptions = {
241
241
  };
242
242
 
243
243
  /**
244
- * Autosize a composer textarea up to `maxPx` without the classic height→0
245
- * measure collapse (that flash expands the timeline flex sibling and yanks
246
- * tip-follow while a stream is pinned).
244
+ * Off-document mirror for shrink/steady measure. Measuring with
245
+ * `height: auto` on the live `rows={1}` textarea collapses it for a layout
246
+ * frame, expands the flex timeline sibling, and yanks tip-follow the
247
+ * multi-line typing flicker that stops only once the box is capped.
248
+ */
249
+ let composerHeightMirror: HTMLTextAreaElement | null = null;
250
+
251
+ function measureComposerContentHeight(textarea: HTMLTextAreaElement): number {
252
+ if (typeof document === "undefined") {
253
+ return textarea.scrollHeight;
254
+ }
255
+ const width = textarea.clientWidth;
256
+ if (width <= 0) {
257
+ return textarea.offsetHeight;
258
+ }
259
+
260
+ let mirror = composerHeightMirror;
261
+ if (!mirror) {
262
+ mirror = document.createElement("textarea");
263
+ mirror.setAttribute("aria-hidden", "true");
264
+ mirror.tabIndex = -1;
265
+ mirror.rows = 1;
266
+ mirror.style.cssText =
267
+ "position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;height:auto;min-height:0;max-height:none;overflow:hidden;z-index:-1;";
268
+ composerHeightMirror = mirror;
269
+ }
270
+
271
+ const style = getComputedStyle(textarea);
272
+ mirror.style.width = `${width}px`;
273
+ mirror.style.boxSizing = style.boxSizing;
274
+ mirror.style.font = style.font;
275
+ mirror.style.fontSize = style.fontSize;
276
+ mirror.style.fontFamily = style.fontFamily;
277
+ mirror.style.fontWeight = style.fontWeight;
278
+ mirror.style.fontStyle = style.fontStyle;
279
+ mirror.style.letterSpacing = style.letterSpacing;
280
+ mirror.style.lineHeight = style.lineHeight;
281
+ mirror.style.textTransform = style.textTransform;
282
+ mirror.style.paddingTop = style.paddingTop;
283
+ mirror.style.paddingRight = style.paddingRight;
284
+ mirror.style.paddingBottom = style.paddingBottom;
285
+ mirror.style.paddingLeft = style.paddingLeft;
286
+ mirror.style.borderTopWidth = style.borderTopWidth;
287
+ mirror.style.borderRightWidth = style.borderRightWidth;
288
+ mirror.style.borderBottomWidth = style.borderBottomWidth;
289
+ mirror.style.borderLeftWidth = style.borderLeftWidth;
290
+ mirror.style.borderStyle = style.borderStyle;
291
+ mirror.style.whiteSpace = style.whiteSpace;
292
+ mirror.style.wordBreak = style.wordBreak;
293
+ mirror.style.overflowWrap = style.overflowWrap;
294
+ mirror.value = textarea.value;
295
+
296
+ if (!mirror.isConnected) {
297
+ document.documentElement.appendChild(mirror);
298
+ }
299
+ return mirror.scrollHeight;
300
+ }
301
+
302
+ /**
303
+ * Autosize a composer textarea up to `maxPx`. Never writes intermediate
304
+ * `height: auto` / `0` on the live element — only the final pixel height.
305
+ *
306
+ * `measure` is injectable for unit tests; production always uses the off-DOM
307
+ * mirror so shrink/steady never collapses the laid-out box.
247
308
  */
248
309
  export function applyComposerTextareaHeight(
249
310
  textarea: HTMLTextAreaElement,
250
311
  maxPx: number = 220,
312
+ measure: (el: HTMLTextAreaElement) => number = measureComposerContentHeight,
251
313
  ): void {
252
- // Grow path: content already overflows — raise height, no measure collapse.
253
- if (textarea.scrollHeight > textarea.clientHeight + 1) {
254
- textarea.style.height = `${Math.min(textarea.scrollHeight, maxPx)}px`;
255
- return;
256
- }
257
- // Shrink / steady: `auto` measures natural height without flashing empty.
258
314
  const before = textarea.offsetHeight;
259
- textarea.style.height = "auto";
260
- const nextPx = Math.min(textarea.scrollHeight, maxPx);
315
+ // Overflow: content taller than the box — scrollHeight is the needed size.
316
+ // Fit/shrink: content fits (or box is oversized) — measure off-DOM.
317
+ const nextPx = Math.min(
318
+ textarea.scrollHeight > textarea.clientHeight + 1 ? textarea.scrollHeight : measure(textarea),
319
+ maxPx,
320
+ );
261
321
  if (Math.abs(nextPx - before) < 1) {
262
- textarea.style.height = `${before}px`;
263
322
  return;
264
323
  }
265
324
  textarea.style.height = `${nextPx}px`;
@@ -934,7 +993,8 @@ export const Footer = forwardRef<HTMLDivElement, ComposerFooterProps>(function C
934
993
  ref={ref}
935
994
  className={cn(
936
995
  "flex items-end gap-1.5 px-2 pb-2 pt-0.5 sm:px-2.5 sm:pb-2.5",
937
- "max-sm:items-center max-sm:gap-1",
996
+ // Mobile: one control row — never wrap into a second toolbar line.
997
+ "max-sm:flex-nowrap max-sm:items-center max-sm:gap-1",
938
998
  className,
939
999
  )}
940
1000
  />
@@ -987,7 +1047,11 @@ export const Actions = forwardRef<HTMLSpanElement, ComposerActionsProps>(functio
987
1047
  <span
988
1048
  {...props}
989
1049
  ref={ref}
990
- className={cn("ml-auto flex shrink-0 items-center gap-1.5", className)}
1050
+ className={cn(
1051
+ "ml-auto flex shrink-0 items-center gap-1.5",
1052
+ "max-sm:flex-nowrap max-sm:gap-1",
1053
+ className,
1054
+ )}
991
1055
  />
992
1056
  );
993
1057
  });
@@ -545,7 +545,7 @@ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
545
545
  disabled={props.disabled}
546
546
  aria-label={messages.label}
547
547
  className={cn(
548
- "inline-flex h-8 max-w-64 items-center gap-1 rounded-full border border-transparent px-2.5 text-xs text-og-fg-muted outline-none transition-colors hover:border-og-border hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:cursor-not-allowed disabled:opacity-50 max-sm:h-7 max-sm:max-w-[9rem] max-sm:px-2",
548
+ "inline-flex h-8 min-w-0 max-w-64 items-center gap-1 rounded-full border border-transparent px-2.5 text-xs text-og-fg-muted outline-none transition-colors hover:border-og-border hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:cursor-not-allowed disabled:opacity-50 max-sm:h-7 max-sm:max-w-[7.5rem] max-sm:px-2",
549
549
  props.className,
550
550
  )}
551
551
  >
@@ -556,7 +556,12 @@ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
556
556
  }
557
557
  className="text-og-fg"
558
558
  />
559
- <span className="truncate font-medium text-og-fg">{selected?.label ?? props.model}</span>
559
+ <span className="min-w-0 truncate font-medium text-og-fg max-sm:hidden">
560
+ {selected?.label ?? props.model}
561
+ </span>
562
+ <span className="min-w-0 truncate font-medium text-og-fg sm:hidden">
563
+ {selected?.shortLabel ?? selected?.label ?? props.model}
564
+ </span>
560
565
  <span className="max-sm:hidden">{labelReasoningEffort(props.effort)}</span>
561
566
  {props.latencyMode === "fast" ? (
562
567
  <ZapIcon
@@ -11,7 +11,7 @@
11
11
  * | --- | --- |
12
12
  * | `--og-session-chrome-surface` / `-open` | Dock fill (collapsed / expanded) |
13
13
  * | `--og-session-chrome-border` / `-open` | Dock edge |
14
- * | `--og-session-chrome-highlight` | Sliding chip selection pill |
14
+ * | `--og-session-chrome-highlight` / `-ring` | Sliding chip selection fill + edge |
15
15
  * | `--og-session-chrome-shadow` / `-open` | Elevation |
16
16
  * | `--og-session-chrome-radius` | Dock corner radius |
17
17
  * | `--og-session-chrome-chip-min-height` | Signal chip height |
@@ -394,7 +394,7 @@ export function SessionChrome({
394
394
  const chipRefs = useRef<Partial<Record<SessionChromeSignalId, HTMLButtonElement | null>>>({});
395
395
  const railRef = useRef<HTMLDivElement | null>(null);
396
396
  const panelBodyRef = useRef<HTMLDivElement | null>(null);
397
- const [pill, setPill] = useState({ left: 0, width: 0, opacity: 0 });
397
+ const [pill, setPill] = useState({ left: 0, top: 0, width: 0, height: 0, opacity: 0 });
398
398
  const [panelHeight, setPanelHeight] = useState(0);
399
399
 
400
400
  const signalIds = signals.map((signal) => signal.id).join(",");
@@ -421,11 +421,15 @@ export function SessionChrome({
421
421
  }
422
422
  const chip = chipRefs.current[active];
423
423
  if (!chip) return;
424
+ // Measure against the chip's own box so a wrapped multi-row rail never
425
+ // stretches the highlight into a tall stripe across every signal.
424
426
  const railBox = rail.getBoundingClientRect();
425
427
  const chipBox = chip.getBoundingClientRect();
426
428
  setPill({
427
429
  left: chipBox.left - railBox.left,
430
+ top: chipBox.top - railBox.top,
428
431
  width: chipBox.width,
432
+ height: chipBox.height,
429
433
  opacity: 1,
430
434
  });
431
435
  };
@@ -433,6 +437,9 @@ export function SessionChrome({
433
437
  if (!rail) return;
434
438
  const observer = new ResizeObserver(measure);
435
439
  observer.observe(rail);
440
+ for (const chip of Object.values(chipRefs.current)) {
441
+ if (chip) observer.observe(chip);
442
+ }
436
443
  window.addEventListener("resize", measure);
437
444
  return () => {
438
445
  observer.disconnect();
@@ -568,88 +575,92 @@ export function SessionChrome({
568
575
  }}
569
576
  >
570
577
  <div
571
- ref={railRef}
572
- className="relative flex flex-wrap items-stretch"
578
+ className="relative"
573
579
  style={{
574
- gap: "var(--og-session-chrome-chip-gap)",
575
- padding: "var(--og-session-chrome-rail-pad)",
580
+ paddingTop: "var(--og-session-chrome-rail-pad)",
581
+ paddingBottom: "var(--og-session-chrome-rail-pad)",
582
+ paddingLeft: "var(--og-session-chrome-rail-pad)",
583
+ paddingRight: "var(--og-session-chrome-rail-pad)",
576
584
  }}
577
585
  >
578
- <motion.div
579
- aria-hidden
580
- className="pointer-events-none absolute rounded-og-md ring-1 ring-og-border/50"
586
+ <div
587
+ ref={railRef}
588
+ className="relative flex flex-wrap items-center"
581
589
  style={{
582
- top: "var(--og-session-chrome-rail-pad)",
583
- bottom: "var(--og-session-chrome-rail-pad)",
584
- background: "var(--og-session-chrome-highlight)",
590
+ gap: "var(--og-session-chrome-chip-gap)",
585
591
  }}
586
- initial={false}
587
- animate={{
588
- x: pill.left,
589
- width: pill.width,
590
- opacity: pill.opacity,
591
- }}
592
- transition={{ duration: shellDuration, ease }}
593
- />
594
- {signals.map((signal) => {
595
- const selected = active === signal.id;
596
- return (
597
- <button
598
- key={signal.id}
599
- type="button"
600
- ref={(node) => {
601
- chipRefs.current[signal.id] = node;
602
- }}
603
- aria-expanded={selected}
604
- aria-controls={panelId}
605
- data-testid={`session-chrome-${signal.id}`}
606
- data-og-session-chrome-signal={signal.id}
607
- onClick={() => setActive(selected ? null : signal.id)}
608
- className={cn(
609
- "group relative z-[1] inline-flex min-h-[var(--og-session-chrome-chip-min-height)] max-w-full items-center gap-1 rounded-og-md text-left text-og-xs outline-none",
610
- "transition-colors duration-150 motion-reduce:transition-none",
611
- "hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40",
612
- "pointer-coarse:min-h-11",
613
- selected ? "text-og-fg" : "text-og-fg-muted",
614
- )}
615
- style={{
616
- paddingInline: "var(--og-session-chrome-chip-pad-x)",
617
- }}
618
- >
619
- <span className={cn("shrink-0", toneClass(signal.tone, selected))}>
620
- {signal.icon}
621
- </span>
622
- <span className="shrink-0 font-medium text-og-fg">{signal.label}</span>
623
- {signal.detail ? (
624
- <>
625
- <span aria-hidden className="shrink-0 text-og-fg-subtle/60">
626
- ·
627
- </span>
628
- <span className="min-w-0 max-w-[8.5rem] truncate text-og-fg sm:max-w-[12rem]">
629
- {signal.detail}
592
+ >
593
+ <motion.div
594
+ aria-hidden
595
+ className="pointer-events-none absolute left-0 top-0 rounded-og-md"
596
+ style={{
597
+ background: "var(--og-session-chrome-highlight)",
598
+ boxShadow: "inset 0 0 0 1px var(--og-session-chrome-highlight-ring)",
599
+ }}
600
+ initial={false}
601
+ animate={{
602
+ x: pill.left,
603
+ y: pill.top,
604
+ width: pill.width,
605
+ height: pill.height,
606
+ opacity: pill.opacity,
607
+ }}
608
+ transition={{ duration: shellDuration, ease }}
609
+ />
610
+ {signals.map((signal) => {
611
+ const selected = active === signal.id;
612
+ return (
613
+ <button
614
+ key={signal.id}
615
+ type="button"
616
+ ref={(node) => {
617
+ chipRefs.current[signal.id] = node;
618
+ }}
619
+ aria-expanded={selected}
620
+ aria-controls={panelId}
621
+ aria-label={selected ? `Close ${signal.label}` : undefined}
622
+ data-testid={`session-chrome-${signal.id}`}
623
+ data-og-session-chrome-signal={signal.id}
624
+ onClick={() => setActive(selected ? null : signal.id)}
625
+ className={cn(
626
+ "group relative z-[1] inline-flex min-h-[var(--og-session-chrome-chip-min-height)] max-w-full items-center gap-1 rounded-og-md py-1 text-left text-og-xs outline-none",
627
+ // Coarse pointers keep a 44px target (session-pins acceptance).
628
+ "pointer-coarse:min-h-11",
629
+ "transition-colors duration-150 motion-reduce:transition-none",
630
+ "hover:text-og-fg focus-visible:bg-og-surface-3/50",
631
+ selected ? "text-og-fg" : "text-og-fg-muted",
632
+ )}
633
+ style={{
634
+ paddingInline: "var(--og-session-chrome-chip-pad-x)",
635
+ }}
636
+ >
637
+ <span className={cn("shrink-0", toneClass(signal.tone, selected))}>
638
+ {signal.icon}
639
+ </span>
640
+ <span className="shrink-0 font-medium text-og-fg">{signal.label}</span>
641
+ {signal.detail ? (
642
+ <>
643
+ <span aria-hidden className="shrink-0 text-og-fg-subtle/60">
644
+ ·
645
+ </span>
646
+ <span className="min-w-0 max-w-[8.5rem] truncate text-og-fg sm:max-w-[12rem]">
647
+ {signal.detail}
648
+ </span>
649
+ </>
650
+ ) : null}
651
+ {selected ? (
652
+ <span
653
+ data-testid="session-chrome-close"
654
+ className="ml-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-og-sm text-og-fg-subtle transition-colors group-hover:text-og-fg pointer-coarse:size-5"
655
+ aria-hidden
656
+ >
657
+ <XIcon className="size-3" />
630
658
  </span>
631
- </>
632
- ) : null}
633
- </button>
634
- );
635
- })}
636
- <AnimatePresence initial={false}>
637
- {open ? (
638
- <motion.button
639
- key="close"
640
- type="button"
641
- aria-label="Close session chrome panel"
642
- onClick={() => setActive(null)}
643
- initial={reduceMotion ? false : { opacity: 0 }}
644
- animate={{ opacity: 1 }}
645
- exit={reduceMotion ? { opacity: 1 } : { opacity: 0 }}
646
- transition={{ duration: crossfadeDuration, ease }}
647
- className="relative z-[1] ml-auto inline-flex size-7 shrink-0 items-center justify-center rounded-og-md text-og-fg-subtle outline-none transition-colors hover:bg-og-surface-3/60 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 pointer-coarse:size-11"
648
- >
649
- <XIcon className="size-3" />
650
- </motion.button>
651
- ) : null}
652
- </AnimatePresence>
659
+ ) : null}
660
+ </button>
661
+ );
662
+ })}
663
+ </div>
653
664
  </div>
654
665
 
655
666
  <motion.div
package/src/composer.ts CHANGED
@@ -71,9 +71,38 @@ export type {
71
71
  ComposerTranscriptionControlProps,
72
72
  ComposerTranscriptionMessages,
73
73
  } from "./components/composer-transcription-control";
74
- export { useVoiceInput } from "./hooks/use-voice-input";
74
+ export {
75
+ VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
76
+ VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS,
77
+ VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
78
+ VOICE_RECORDING_TIMESLICE_MILLISECONDS,
79
+ useVoiceInput,
80
+ } from "./hooks/use-voice-input";
75
81
  export type {
76
82
  UseVoiceInputOptions,
77
83
  UseVoiceInputResult,
78
84
  VoiceInputStatus,
79
85
  } from "./hooks/use-voice-input";
86
+ export {
87
+ IndexedDbVoiceRecordingStore,
88
+ VoiceRecordingChunkConflictError,
89
+ VoiceRecordingChunkSequenceError,
90
+ VoiceRecordingNotFoundError,
91
+ VoiceRecordingOwnedError,
92
+ VoiceRecordingStorageUnavailableError,
93
+ createVoiceRecordingManifest,
94
+ planVoiceRecordingChunkCommit,
95
+ prepareVoiceRecordingChunk,
96
+ } from "./voice-recording-store";
97
+ export type {
98
+ PersistVoiceRecordingChunkInput,
99
+ PersistVoiceRecordingChunkResult,
100
+ VoiceRecordingCaptureState,
101
+ VoiceRecordingChunk,
102
+ VoiceRecordingChunkUploadState,
103
+ VoiceRecordingFinalizationState,
104
+ VoiceRecordingManifest,
105
+ VoiceRecordingStore,
106
+ VoiceRecordingTranscriptionState,
107
+ VoiceRecordingUploadState,
108
+ } from "./voice-recording-store";