@opengeni/react 0.34.2 → 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.
Files changed (59) hide show
  1. package/dist/{chunk-A2WITZTI.js → chunk-4NMH6UJ3.js} +2 -2
  2. package/dist/{chunk-GZ4KR67U.js → chunk-ALA3UGWB.js} +2 -2
  3. package/dist/chunk-ATSTR5P3.js +138 -0
  4. package/dist/chunk-ATSTR5P3.js.map +1 -0
  5. package/dist/{chunk-KZRUYCL4.js → chunk-FT2TXNGZ.js} +5 -2
  6. package/dist/chunk-FT2TXNGZ.js.map +1 -0
  7. package/dist/{chunk-4QOQ2DSR.js → chunk-LAKLF4PA.js} +14 -1
  8. package/dist/chunk-LAKLF4PA.js.map +1 -0
  9. package/dist/{chunk-TCCE5ZU5.js → chunk-MEGI4FS6.js} +38 -9
  10. package/dist/chunk-MEGI4FS6.js.map +1 -0
  11. package/dist/{chunk-MS3FBZ6A.js → chunk-PVW56EVR.js} +105 -62
  12. package/dist/chunk-PVW56EVR.js.map +1 -0
  13. package/dist/{chunk-L7R5RK6A.js → chunk-QRYJCWGD.js} +1199 -897
  14. package/dist/chunk-QRYJCWGD.js.map +1 -0
  15. package/dist/components/chat-composer.d.ts +2 -1
  16. package/dist/components/composer.d.ts +8 -2
  17. package/dist/components/copy-button.d.ts +4 -2
  18. package/dist/components/tip-follow.d.ts +3 -0
  19. package/dist/composer.d.ts +2 -1
  20. package/dist/composer.js +4 -3
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +105 -101
  23. package/dist/index.js.map +1 -1
  24. package/dist/lib/format.d.ts +2 -0
  25. package/dist/machines.js +2 -2
  26. package/dist/session-ui.js +4 -3
  27. package/dist/session.js +4 -4
  28. package/dist/timeline/index.d.ts +1 -1
  29. package/dist/timeline/parsers.d.ts +5 -0
  30. package/dist/timeline/projection.d.ts +1 -12
  31. package/dist/timeline/tool-display-name.d.ts +17 -6
  32. package/dist/workstream-control-event.d.ts +6 -0
  33. package/package.json +1 -1
  34. package/src/components/chat-composer.tsx +1 -1
  35. package/src/components/composer-transcription-control.tsx +70 -57
  36. package/src/components/composer.tsx +197 -126
  37. package/src/components/copy-button.tsx +27 -8
  38. package/src/components/message-timeline.tsx +236 -39
  39. package/src/components/tip-follow.ts +15 -0
  40. package/src/composer.ts +1 -1
  41. package/src/hooks/use-composer.ts +7 -1
  42. package/src/index.ts +2 -1
  43. package/src/lib/format.ts +14 -0
  44. package/src/timeline/index.ts +2 -0
  45. package/src/timeline/parsers.ts +18 -1
  46. package/src/timeline/projection.ts +50 -20
  47. package/src/timeline/registry.ts +15 -2
  48. package/src/timeline/shared.tsx +30 -28
  49. package/src/timeline/tool-display-name.ts +30 -11
  50. package/src/timeline/tool-renderers.tsx +347 -16
  51. package/src/timeline/turn-summary.tsx +5 -2
  52. package/src/workstream-control-event.ts +6 -0
  53. package/dist/chunk-4QOQ2DSR.js.map +0 -1
  54. package/dist/chunk-KZRUYCL4.js.map +0 -1
  55. package/dist/chunk-L7R5RK6A.js.map +0 -1
  56. package/dist/chunk-MS3FBZ6A.js.map +0 -1
  57. package/dist/chunk-TCCE5ZU5.js.map +0 -1
  58. /package/dist/{chunk-A2WITZTI.js.map → chunk-4NMH6UJ3.js.map} +0 -0
  59. /package/dist/{chunk-GZ4KR67U.js.map → chunk-ALA3UGWB.js.map} +0 -0
@@ -13,6 +13,7 @@ import {
13
13
  } from "lucide-react";
14
14
  import { AnimatePresence, motion } from "motion/react";
15
15
  import {
16
+ cloneElement,
16
17
  createContext,
17
18
  forwardRef,
18
19
  useCallback,
@@ -28,6 +29,7 @@ import {
28
29
  type ComponentPropsWithoutRef,
29
30
  type DragEvent,
30
31
  type KeyboardEvent,
32
+ type ReactElement,
31
33
  type ReactNode,
32
34
  type Ref,
33
35
  type RefObject,
@@ -45,10 +47,23 @@ import {
45
47
  import { cn } from "../lib/cn";
46
48
  import { composerSubmissionErrorMessage, formatBytes, formatRelativeTime } from "../lib/format";
47
49
  import type { PickerModelRow } from "../model-policy";
50
+ import { OPEN_WORKSTREAM_CONTROL_EVENT } from "../workstream-control-event";
48
51
  import { CommandPalette as CommandPaletteView } from "./command-palette";
49
52
  import { ModelPicker as ModelPickerView } from "./model-picker";
53
+ import { TooltipProvider } from "./tooltip";
50
54
 
51
- export const OPEN_WORKSTREAM_CONTROL_EVENT = "opengeni:open-workstream-control";
55
+ export { OPEN_WORKSTREAM_CONTROL_EVENT };
56
+
57
+ function ComposerTip({
58
+ tip,
59
+ children,
60
+ }: {
61
+ tip: string;
62
+ children: ReactElement<{ title?: string }>;
63
+ }) {
64
+ if (!tip) return children;
65
+ return cloneElement(children, { title: tip });
66
+ }
52
67
 
53
68
  export type ComposerDelivery = Pick<
54
69
  ComposerState,
@@ -225,6 +240,31 @@ export type UseChatComposerControllerOptions = {
225
240
  messages?: Partial<ChatComposerMessages> | undefined;
226
241
  };
227
242
 
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).
247
+ */
248
+ export function applyComposerTextareaHeight(
249
+ textarea: HTMLTextAreaElement,
250
+ maxPx: number = 220,
251
+ ): 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
+ const before = textarea.offsetHeight;
259
+ textarea.style.height = "auto";
260
+ const nextPx = Math.min(textarea.scrollHeight, maxPx);
261
+ if (Math.abs(nextPx - before) < 1) {
262
+ textarea.style.height = `${before}px`;
263
+ return;
264
+ }
265
+ textarea.style.height = `${nextPx}px`;
266
+ }
267
+
228
268
  /**
229
269
  * Headless interaction layer for a chat composer. It is the single owner of
230
270
  * delivery guards, keyboard routing, command interception, drag/drop, focus,
@@ -347,13 +387,33 @@ export function useChatComposerController({
347
387
  [],
348
388
  );
349
389
 
390
+ // Autosize without collapsing the composer to 0 on every keystroke. The old
391
+ // height→0→content pattern briefly expanded the timeline scroller and yanked
392
+ // tip-follow while streams were live (pin/unpin flicker when typing fast).
393
+ const resizeInputRafRef = useRef<number | null>(null);
350
394
  const resizeInput = useCallback(() => {
351
- const textarea = textareaRef.current;
352
- if (!textarea) return;
353
- textarea.style.height = "0px";
354
- textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`;
395
+ if (resizeInputRafRef.current !== null) {
396
+ return;
397
+ }
398
+ resizeInputRafRef.current = requestAnimationFrame(() => {
399
+ resizeInputRafRef.current = null;
400
+ const textarea = textareaRef.current;
401
+ if (!textarea) {
402
+ return;
403
+ }
404
+ applyComposerTextareaHeight(textarea, 220);
405
+ });
355
406
  }, []);
356
407
  useEffect(() => resizeInput(), [delivery.value, resizeInput]);
408
+ useEffect(
409
+ () => () => {
410
+ if (resizeInputRafRef.current !== null) {
411
+ cancelAnimationFrame(resizeInputRafRef.current);
412
+ resizeInputRafRef.current = null;
413
+ }
414
+ },
415
+ [],
416
+ );
357
417
 
358
418
  const handlers = useMemo(
359
419
  () => ({
@@ -630,18 +690,23 @@ export const Root = forwardRef<HTMLDivElement, ComposerRootProps>(function Compo
630
690
  { controller, children, className, style, ...props },
631
691
  forwardedRef,
632
692
  ) {
693
+ // Provider stays on Root so optional Radix tip surfaces (voice input) work.
694
+ // Ordinary composer actions use native `title` via ComposerTip to avoid
695
+ // pulling Popper into every tip hotspot.
633
696
  return (
634
697
  <ComposerContext.Provider value={controller}>
635
- <div
636
- {...props}
637
- ref={mergeRefs(controller.rootRef, forwardedRef)}
638
- data-og-composer-id={controller.id}
639
- className={cn("og-root", className)}
640
- style={{ paddingBottom: "env(safe-area-inset-bottom)", ...style }}
641
- >
642
- {children}
643
- <ComposerAnnouncements />
644
- </div>
698
+ <TooltipProvider delayDuration={300}>
699
+ <div
700
+ {...props}
701
+ ref={mergeRefs(controller.rootRef, forwardedRef)}
702
+ data-og-composer-id={controller.id}
703
+ className={cn("og-root", className)}
704
+ style={{ paddingBottom: "env(safe-area-inset-bottom)", ...style }}
705
+ >
706
+ {children}
707
+ <ComposerAnnouncements />
708
+ </div>
709
+ </TooltipProvider>
645
710
  </ComposerContext.Provider>
646
711
  );
647
712
  });
@@ -919,6 +984,7 @@ export const AttachButton = forwardRef<HTMLButtonElement, ComposerAttachButtonPr
919
984
  ) {
920
985
  const controller = useComposerController();
921
986
  if (!controller.attachments) return null;
987
+ const tip = title ?? controller.messages.attachFiles;
922
988
  return (
923
989
  <>
924
990
  <input
@@ -929,23 +995,24 @@ export const AttachButton = forwardRef<HTMLButtonElement, ComposerAttachButtonPr
929
995
  className="hidden"
930
996
  onChange={controller.handleFileChange}
931
997
  />
932
- <button
933
- {...props}
934
- ref={ref}
935
- type="button"
936
- disabled={controller.disabled}
937
- onClick={() => controller.fileInputRef.current?.click()}
938
- aria-label={ariaLabel ?? controller.messages.attachFiles}
939
- title={title ?? controller.messages.attachFiles}
940
- className={cn(
941
- "inline-flex size-8 items-center justify-center rounded-og-md",
942
- "text-og-fg-muted transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
943
- "disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-11",
944
- className,
945
- )}
946
- >
947
- {children ?? <PaperclipIcon className="size-4" />}
948
- </button>
998
+ <ComposerTip tip={tip}>
999
+ <button
1000
+ {...props}
1001
+ ref={ref}
1002
+ type="button"
1003
+ disabled={controller.disabled}
1004
+ onClick={() => controller.fileInputRef.current?.click()}
1005
+ aria-label={ariaLabel ?? tip}
1006
+ className={cn(
1007
+ "inline-flex size-8 items-center justify-center rounded-og-md",
1008
+ "text-og-fg-muted transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
1009
+ "disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-11",
1010
+ className,
1011
+ )}
1012
+ >
1013
+ {children ?? <PaperclipIcon className="size-4" />}
1014
+ </button>
1015
+ </ComposerTip>
949
1016
  </>
950
1017
  );
951
1018
  },
@@ -1000,30 +1067,32 @@ export const PauseButton = forwardRef<HTMLButtonElement, ComposerPauseButtonProp
1000
1067
  const controller = useComposerController();
1001
1068
  if (!controller.effectiveControl || controller.paused || !controller.hasControl) return null;
1002
1069
  const busy = controller.pausing || controller.resuming;
1070
+ const tip = title ?? controller.messages.pauseTitle;
1003
1071
  return (
1004
- <button
1005
- {...props}
1006
- ref={mergeRefs(controller.pauseButtonRef, ref)}
1007
- type="button"
1008
- onClick={() => void controller.pause()}
1009
- disabled={busy}
1010
- aria-label={ariaLabel ?? controller.messages.pauseAriaLabel}
1011
- title={title ?? controller.messages.pauseTitle}
1012
- className={cn(
1013
- "inline-flex size-8 items-center justify-center rounded-og-md border border-og-border pointer-coarse:size-11",
1014
- "bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
1015
- "hover:border-og-status-waiting/50 hover:text-og-status-waiting",
1016
- "disabled:opacity-50",
1017
- className,
1018
- )}
1019
- >
1020
- {children ??
1021
- (busy ? (
1022
- <LoaderCircleIcon className="size-3.5 animate-og-spin" />
1023
- ) : (
1024
- <PauseIcon className="size-3.5 fill-current" />
1025
- ))}
1026
- </button>
1072
+ <ComposerTip tip={tip}>
1073
+ <button
1074
+ {...props}
1075
+ ref={mergeRefs(controller.pauseButtonRef, ref)}
1076
+ type="button"
1077
+ onClick={() => void controller.pause()}
1078
+ disabled={busy}
1079
+ aria-label={ariaLabel ?? controller.messages.pauseAriaLabel}
1080
+ className={cn(
1081
+ "inline-flex size-8 items-center justify-center rounded-og-md border border-og-border pointer-coarse:size-11",
1082
+ "bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
1083
+ "hover:border-og-status-waiting/50 hover:text-og-status-waiting",
1084
+ "disabled:opacity-50",
1085
+ className,
1086
+ )}
1087
+ >
1088
+ {children ??
1089
+ (busy ? (
1090
+ <LoaderCircleIcon className="size-3.5 animate-og-spin" />
1091
+ ) : (
1092
+ <PauseIcon className="size-3.5 fill-current" />
1093
+ ))}
1094
+ </button>
1095
+ </ComposerTip>
1027
1096
  );
1028
1097
  },
1029
1098
  );
@@ -1039,41 +1108,41 @@ export const SendButton = forwardRef<HTMLButtonElement, ComposerSendButtonProps>
1039
1108
  ref,
1040
1109
  ) {
1041
1110
  const controller = useComposerController();
1111
+ const tip =
1112
+ title ??
1113
+ (controller.paused ? controller.messages.sendAndResumeTitle : controller.messages.sendTitle);
1042
1114
  return (
1043
- <button
1044
- {...props}
1045
- ref={ref}
1046
- type="button"
1047
- onClick={() => void controller.submit("queue")}
1048
- disabled={!controller.canSubmit}
1049
- aria-label={
1050
- ariaLabel ??
1051
- (controller.paused
1052
- ? controller.messages.sendAndResumeAriaLabel
1053
- : controller.messages.sendMessageAriaLabel)
1054
- }
1055
- title={
1056
- title ??
1057
- (controller.paused
1058
- ? controller.messages.sendAndResumeTitle
1059
- : controller.messages.sendTitle)
1060
- }
1061
- className={cn(
1062
- "inline-flex size-8 items-center justify-center rounded-og-md pointer-coarse:size-11",
1063
- "bg-og-accent text-og-accent-fg shadow-og-sm",
1064
- "transition-[background-color,transform,opacity] duration-150 ease-og-spring",
1065
- "hover:bg-og-accent-strong active:scale-95",
1066
- "disabled:cursor-not-allowed disabled:bg-og-surface-3 disabled:text-og-fg-subtle disabled:shadow-none",
1067
- className,
1068
- )}
1069
- >
1070
- {children ??
1071
- (controller.sending ? (
1072
- <LoaderCircleIcon className="size-4 animate-og-spin" />
1073
- ) : (
1074
- <ArrowUpIcon className="size-4" />
1075
- ))}
1076
- </button>
1115
+ <ComposerTip tip={tip}>
1116
+ <button
1117
+ {...props}
1118
+ ref={ref}
1119
+ type="button"
1120
+ onClick={() => void controller.submit("queue")}
1121
+ disabled={!controller.canSubmit}
1122
+ data-og-tip={tip}
1123
+ aria-label={
1124
+ ariaLabel ??
1125
+ (controller.paused
1126
+ ? controller.messages.sendAndResumeAriaLabel
1127
+ : controller.messages.sendMessageAriaLabel)
1128
+ }
1129
+ className={cn(
1130
+ "inline-flex size-8 items-center justify-center rounded-og-md pointer-coarse:size-11",
1131
+ "bg-og-accent text-og-accent-fg shadow-og-sm",
1132
+ "transition-[background-color,transform,opacity] duration-150 ease-og-spring",
1133
+ "hover:bg-og-accent-strong active:scale-95",
1134
+ "disabled:cursor-not-allowed disabled:bg-og-surface-3 disabled:text-og-fg-subtle disabled:shadow-none",
1135
+ className,
1136
+ )}
1137
+ >
1138
+ {children ??
1139
+ (controller.sending ? (
1140
+ <LoaderCircleIcon className="size-4 animate-og-spin" />
1141
+ ) : (
1142
+ <ArrowUpIcon className="size-4" />
1143
+ ))}
1144
+ </button>
1145
+ </ComposerTip>
1077
1146
  );
1078
1147
  },
1079
1148
  );
@@ -1275,27 +1344,30 @@ function WorkstreamPausedStrip({
1275
1344
  {broaderOptions.length > 0 ? (
1276
1345
  <div className="mt-2 flex flex-wrap gap-1.5">
1277
1346
  {broaderOptions.map((option) => (
1278
- <button
1347
+ <ComposerTip
1279
1348
  key={`${option.scope}-${option.targetId ?? "selected"}`}
1280
- type="button"
1281
- disabled={busy}
1282
- title={option.impactCopy}
1283
- onClick={() => onResumeOption(option)}
1284
- className="rounded-og-md border border-og-border bg-og-surface-1 px-2 py-1 text-og-xs text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg pointer-coarse:min-h-10"
1349
+ tip={option.impactCopy}
1285
1350
  >
1286
- <span className="block font-medium">
1287
- {option.scope === "workspace"
1288
- ? messages.resumeWorkspace
1289
- : messages.resumeFromSession}
1290
- </span>
1291
- <span className="block text-[10px] text-og-fg-subtle">
1292
- {option.selectedStateAfter === "active"
1293
- ? messages.sessionCanRun
1294
- : messages.stillPausedBy(
1295
- option.remainingPrimaryBlocker?.displayName ?? messages.narrowerPause,
1296
- )}
1297
- </span>
1298
- </button>
1351
+ <button
1352
+ type="button"
1353
+ disabled={busy}
1354
+ onClick={() => onResumeOption(option)}
1355
+ className="rounded-og-md border border-og-border bg-og-surface-1 px-2 py-1 text-og-xs text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg pointer-coarse:min-h-10"
1356
+ >
1357
+ <span className="block font-medium">
1358
+ {option.scope === "workspace"
1359
+ ? messages.resumeWorkspace
1360
+ : messages.resumeFromSession}
1361
+ </span>
1362
+ <span className="block text-[10px] text-og-fg-subtle">
1363
+ {option.selectedStateAfter === "active"
1364
+ ? messages.sessionCanRun
1365
+ : messages.stillPausedBy(
1366
+ option.remainingPrimaryBlocker?.displayName ?? messages.narrowerPause,
1367
+ )}
1368
+ </span>
1369
+ </button>
1370
+ </ComposerTip>
1299
1371
  ))}
1300
1372
  </div>
1301
1373
  ) : null}
@@ -1453,29 +1525,28 @@ function AttachmentChips({
1453
1525
  )}
1454
1526
  <div className="min-w-0 flex-1">
1455
1527
  <div className="truncate font-medium text-og-fg">{attachment.name}</div>
1456
- <div
1457
- className={cn(
1458
- "truncate text-og-xs",
1459
- failed ? "text-og-status-failed" : "text-og-fg-subtle",
1460
- )}
1461
- title={failed ? statusText : undefined}
1462
- >
1463
- {statusText}
1464
- </div>
1528
+ {failed ? (
1529
+ <ComposerTip tip={statusText}>
1530
+ <div className="truncate text-og-xs text-og-status-failed">{statusText}</div>
1531
+ </ComposerTip>
1532
+ ) : (
1533
+ <div className="truncate text-og-xs text-og-fg-subtle">{statusText}</div>
1534
+ )}
1465
1535
  </div>
1466
1536
  {attachment.status === "uploading" ? (
1467
1537
  <LoaderCircleIcon className="size-3.5 shrink-0 animate-og-spin" />
1468
1538
  ) : null}
1469
1539
  {failed && onRetry ? (
1470
- <button
1471
- type="button"
1472
- onClick={() => onRetry(attachment.id)}
1473
- className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-10"
1474
- aria-label={messages.retryAttachment(attachment.name)}
1475
- title={messages.retryUpload}
1476
- >
1477
- <RotateCwIcon className="size-3.5" />
1478
- </button>
1540
+ <ComposerTip tip={messages.retryUpload}>
1541
+ <button
1542
+ type="button"
1543
+ onClick={() => onRetry(attachment.id)}
1544
+ className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-10"
1545
+ aria-label={messages.retryAttachment(attachment.name)}
1546
+ >
1547
+ <RotateCwIcon className="size-3.5" />
1548
+ </button>
1549
+ </ComposerTip>
1479
1550
  ) : null}
1480
1551
  <button
1481
1552
  type="button"
@@ -14,7 +14,7 @@ export type CopyButtonProps = {
14
14
  className?: string | undefined;
15
15
  /**
16
16
  * `always` — visible control.
17
- * `group-hover` — parent must use `group/copy`; fades in on hover/focus.
17
+ * `group-hover` — parent must use `group/copy`; fades in on hover/focus-visible.
18
18
  */
19
19
  reveal?: "always" | "group-hover" | undefined;
20
20
  };
@@ -30,6 +30,7 @@ export function CopyButton({
30
30
  reveal = "group-hover",
31
31
  }: CopyButtonProps) {
32
32
  const [copied, setCopied] = useState(false);
33
+ const [tipOpen, setTipOpen] = useState(false);
33
34
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
35
 
35
36
  useEffect(() => {
@@ -49,19 +50,34 @@ export function CopyButton({
49
50
  return;
50
51
  }
51
52
  setCopied(true);
53
+ setTipOpen(true);
54
+ // Pointer activation focuses the button and leaves `group-focus-within`
55
+ // stuck after the cursor leaves — chrome never "unhovers". Keyboard
56
+ // activation (`detail === 0`) keeps focus for a11y.
57
+ if (event.detail > 0) {
58
+ event.currentTarget.blur();
59
+ }
52
60
  if (timerRef.current !== null) {
53
61
  clearTimeout(timerRef.current);
54
62
  }
55
63
  timerRef.current = setTimeout(() => {
56
64
  timerRef.current = null;
57
65
  setCopied(false);
66
+ setTipOpen(false);
58
67
  }, COPIED_MS);
59
68
  };
60
69
 
61
70
  const tip = copied ? "Copied" : label;
62
71
 
63
72
  return (
64
- <Tooltip>
73
+ <Tooltip
74
+ open={copied ? true : tipOpen}
75
+ onOpenChange={(next) => {
76
+ if (!copied) {
77
+ setTipOpen(next);
78
+ }
79
+ }}
80
+ >
65
81
  <TooltipTrigger asChild>
66
82
  <button
67
83
  type="button"
@@ -99,28 +115,31 @@ export function CopyHoverFrame({
99
115
  copyText,
100
116
  label,
101
117
  align = "end",
118
+ trailing,
102
119
  }: {
103
120
  children: ReactNode;
104
121
  className?: string | undefined;
105
122
  copyText: string;
106
123
  label: string;
107
124
  align?: "start" | "end" | undefined;
125
+ /** Optional footer meta (e.g. sent/finished clock) — immediately after the copy control. */
126
+ trailing?: ReactNode | undefined;
108
127
  }) {
109
128
  if (copyText.trim().length === 0) {
110
129
  return <div className={className}>{children}</div>;
111
130
  }
112
131
  return (
113
- <div className={cn("group/copy relative", className)}>
132
+ <div className={cn("group/copy", className)}>
114
133
  {children}
134
+ {/* Sit under the body — top-right overlay collided with the first line. */}
115
135
  <div
116
136
  className={cn(
117
- "pointer-events-none absolute top-1 z-10",
118
- align === "end" ? "right-1" : "left-1",
137
+ "mt-1 flex h-7 items-center gap-1.5",
138
+ align === "end" ? "justify-end" : "justify-start",
119
139
  )}
120
140
  >
121
- <div className="pointer-events-auto">
122
- <CopyButton text={copyText} label={label} reveal="group-hover" />
123
- </div>
141
+ <CopyButton text={copyText} label={label} reveal="group-hover" />
142
+ {trailing}
124
143
  </div>
125
144
  </div>
126
145
  );