@opengeni/react 3.8.0-canary.0 → 4.0.1-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +11 -24
  2. package/dist/artifacts.js +7 -7
  3. package/dist/{browser-viewer-SS46LUAC.js → browser-viewer-NTD6UIPR.js} +3 -3
  4. package/dist/{chunk-VEPNNDNG.js → chunk-24M4YBCL.js} +4 -4
  5. package/dist/chunk-5M6VTFJ5.js +171 -0
  6. package/dist/chunk-5M6VTFJ5.js.map +1 -0
  7. package/dist/{chunk-IOLVN74D.js → chunk-6J56T3MG.js} +15 -1
  8. package/dist/chunk-6J56T3MG.js.map +1 -0
  9. package/dist/{chunk-ELIYWBZR.js → chunk-BV5A4OTE.js} +4 -4
  10. package/dist/{chunk-3HIF5IFC.js → chunk-HO6X4FHG.js} +2597 -1030
  11. package/dist/chunk-HO6X4FHG.js.map +1 -0
  12. package/dist/{chunk-BXJOQFCD.js → chunk-HUMIRRYI.js} +84 -221
  13. package/dist/chunk-HUMIRRYI.js.map +1 -0
  14. package/dist/{chunk-ZFOFY5ST.js → chunk-NLTS7JHI.js} +16 -31
  15. package/dist/chunk-NLTS7JHI.js.map +1 -0
  16. package/dist/chunk-NOSYBS2D.js +208 -0
  17. package/dist/chunk-NOSYBS2D.js.map +1 -0
  18. package/dist/chunk-YBM6CQRU.js +292 -0
  19. package/dist/chunk-YBM6CQRU.js.map +1 -0
  20. package/dist/{chunk-BQGIXRKV.js → chunk-Z7WBDB4E.js} +416 -290
  21. package/dist/chunk-Z7WBDB4E.js.map +1 -0
  22. package/dist/chunk-ZYRGEKWS.js +216 -0
  23. package/dist/chunk-ZYRGEKWS.js.map +1 -0
  24. package/dist/components/composer.d.ts +4 -2
  25. package/dist/components/human-input-form.d.ts +3 -1
  26. package/dist/components/human-input-surface.d.ts +3 -2
  27. package/dist/components/message-timeline.d.ts +9 -2
  28. package/dist/components/session-conversation.d.ts +2 -0
  29. package/dist/components/timeline-annotation-chrome.d.ts +25 -0
  30. package/dist/components/timeline-annotation-layout.d.ts +42 -0
  31. package/dist/components/timeline-annotation-markers.d.ts +8 -0
  32. package/dist/components/timeline-annotation-reveal-context.d.ts +4 -0
  33. package/dist/components/timeline-annotation-shared.d.ts +31 -0
  34. package/dist/components/timeline-annotations-dialog.d.ts +1 -1
  35. package/dist/components/timeline-annotations.d.ts +8 -2
  36. package/dist/composer.js +3 -4
  37. package/dist/{computer-viewer-FWEZISW4.js → computer-viewer-7TEZC6Y2.js} +3 -3
  38. package/dist/hooks/use-codex-accounts.d.ts +11 -1
  39. package/dist/hooks/use-composer.d.ts +2 -0
  40. package/dist/hooks/use-session-events.d.ts +2 -2
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +50 -37
  43. package/dist/index.js.map +1 -1
  44. package/dist/interaction.js +3 -3
  45. package/dist/machines.js +1 -1
  46. package/dist/older-history.d.ts +3 -2
  47. package/dist/{platform-activity-row-BJHUHXAT.js → platform-activity-row-27LKAWZD.js} +2 -2
  48. package/dist/platform-activity-row-27LKAWZD.js.map +1 -0
  49. package/dist/{queue-surface-implementation-XL2A6PBM.js → queue-surface-implementation-LK3S4BIW.js} +5 -2
  50. package/dist/{queue-surface-implementation-XL2A6PBM.js.map → queue-surface-implementation-LK3S4BIW.js.map} +1 -1
  51. package/dist/realtime.js +3 -3
  52. package/dist/session-ui.js +11 -11
  53. package/dist/session.js +2 -2
  54. package/dist/timeline/types.d.ts +3 -0
  55. package/dist/timeline-annotation-markers-DRSTLEM6.js +165 -0
  56. package/dist/timeline-annotation-markers-DRSTLEM6.js.map +1 -0
  57. package/dist/timeline-annotation-selection-IHLUGVND.js +236 -0
  58. package/dist/timeline-annotation-selection-IHLUGVND.js.map +1 -0
  59. package/dist/timeline-annotations-dialog-6Q53N5FG.js +262 -0
  60. package/dist/timeline-annotations-dialog-6Q53N5FG.js.map +1 -0
  61. package/package.json +2 -6
  62. package/src/components/chat-composer.tsx +5 -2
  63. package/src/components/composer.tsx +40 -5
  64. package/src/components/copy-button.tsx +1 -0
  65. package/src/components/human-input-form.tsx +185 -53
  66. package/src/components/human-input-surface.tsx +3 -0
  67. package/src/components/machines-dashboard.tsx +1 -16
  68. package/src/components/message-timeline.tsx +397 -302
  69. package/src/components/session-commands-panel.tsx +7 -1
  70. package/src/components/session-conversation.tsx +4 -1
  71. package/src/components/timeline-annotation-chrome.tsx +183 -0
  72. package/src/components/timeline-annotation-layout.ts +268 -0
  73. package/src/components/timeline-annotation-markers.tsx +185 -0
  74. package/src/components/timeline-annotation-reveal-context.ts +9 -0
  75. package/src/components/timeline-annotation-selection.tsx +99 -54
  76. package/src/components/timeline-annotation-shared.ts +245 -0
  77. package/src/components/timeline-annotations-dialog.tsx +172 -128
  78. package/src/components/timeline-annotations.tsx +178 -45
  79. package/src/hooks/use-codex-accounts.ts +32 -5
  80. package/src/hooks/use-composer.ts +17 -0
  81. package/src/hooks/use-session-events.ts +16 -277
  82. package/src/index.ts +0 -1
  83. package/src/older-history.ts +22 -4
  84. package/src/timeline/platform-activity-row.tsx +4 -1
  85. package/src/timeline/projection.ts +99 -9
  86. package/src/timeline/types.ts +3 -0
  87. package/styles/compiled.css +86 -22
  88. package/dist/chat.d.ts +0 -37
  89. package/dist/chat.js +0 -315
  90. package/dist/chat.js.map +0 -1
  91. package/dist/chunk-3HIF5IFC.js.map +0 -1
  92. package/dist/chunk-BQGIXRKV.js.map +0 -1
  93. package/dist/chunk-BXJOQFCD.js.map +0 -1
  94. package/dist/chunk-F2CFHN3Y.js +0 -1415
  95. package/dist/chunk-F2CFHN3Y.js.map +0 -1
  96. package/dist/chunk-HQV2I5HV.js +0 -124
  97. package/dist/chunk-HQV2I5HV.js.map +0 -1
  98. package/dist/chunk-IOLVN74D.js.map +0 -1
  99. package/dist/chunk-W3OUAH6M.js +0 -170
  100. package/dist/chunk-W3OUAH6M.js.map +0 -1
  101. package/dist/chunk-ZFOFY5ST.js.map +0 -1
  102. package/dist/platform-activity-row-BJHUHXAT.js.map +0 -1
  103. package/dist/timeline-annotation-selection-3L7LHGG2.js +0 -187
  104. package/dist/timeline-annotation-selection-3L7LHGG2.js.map +0 -1
  105. package/dist/timeline-annotations-dialog-U7EVLR75.js +0 -196
  106. package/dist/timeline-annotations-dialog-U7EVLR75.js.map +0 -1
  107. package/src/chat.tsx +0 -393
  108. /package/dist/{browser-viewer-SS46LUAC.js.map → browser-viewer-NTD6UIPR.js.map} +0 -0
  109. /package/dist/{chunk-VEPNNDNG.js.map → chunk-24M4YBCL.js.map} +0 -0
  110. /package/dist/{chunk-ELIYWBZR.js.map → chunk-BV5A4OTE.js.map} +0 -0
  111. /package/dist/{computer-viewer-FWEZISW4.js.map → computer-viewer-7TEZC6Y2.js.map} +0 -0
@@ -1,40 +1,28 @@
1
1
  import type { TimelineAnnotationSource } from "@opengeni/sdk";
2
+ import { XIcon } from "lucide-react";
2
3
  import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
3
4
  import { createPortal } from "react-dom";
4
- import type { TimelineAnnotationLike } from "./timeline-annotations";
5
+ import { usePortalTokenStyle } from "../lib/use-portal-token-style";
6
+ import {
7
+ AnnotationAccentRow,
8
+ AnnotationNoteField,
9
+ AnnotationNotePreview,
10
+ AnnotationQuoteSourceButton,
11
+ type TimelineAnnotationLike,
12
+ } from "./timeline-annotation-chrome";
13
+ import {
14
+ ANNOTATION_REVIEW_DIALOG_WIDTH_PX,
15
+ clampAnnotationDialogPlacement,
16
+ scrollAnnotationRowIntoList,
17
+ } from "./timeline-annotation-layout";
18
+ import { annotationDisplayOrdinal, cssEscapeAttribute } from "./timeline-annotation-shared";
5
19
 
6
- function cssEscapeAttribute(value: string): string {
7
- if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
8
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
9
- }
10
-
11
- function revealLoadedSource(source: TimelineAnnotationSource): boolean {
12
- if (typeof document === "undefined") return false;
13
- const element = document.querySelector(
14
- `[data-og-annotation-source-key="${cssEscapeAttribute(source.eventId)}"]`,
15
- );
16
- if (!(element instanceof HTMLElement)) return false;
17
- element.scrollIntoView({ block: "center", behavior: "smooth" });
18
- element.animate?.(
19
- [
20
- { outline: "2px solid color-mix(in srgb, currentColor 55%, transparent)" },
21
- { outline: "2px solid transparent" },
22
- ],
23
- { duration: 1600, easing: "ease-out" },
24
- );
25
- return true;
26
- }
27
-
28
- function sourceLabel(annotation: TimelineAnnotationLike): string {
29
- if (annotation.source.label) return annotation.source.label;
30
- switch (annotation.source.kind) {
31
- case "user_message":
32
- return "User message";
33
- case "assistant_message":
34
- return "Assistant message";
35
- case "tool_output":
36
- return "Tool output";
37
- }
20
+ function focusableElements(root: HTMLElement): HTMLElement[] {
21
+ return [
22
+ ...root.querySelectorAll<HTMLElement>(
23
+ 'button:not([disabled]),textarea:not([disabled]),input:not([disabled]),[href],[tabindex]:not([tabindex="-1"])',
24
+ ),
25
+ ].filter((element) => element.tabIndex >= 0 && !element.closest("[inert]"));
38
26
  }
39
27
 
40
28
  export function TimelineAnnotationsDialog({
@@ -61,31 +49,90 @@ export function TimelineAnnotationsDialog({
61
49
  onDismiss: (restoreFocus: boolean) => void;
62
50
  }) {
63
51
  const [unavailableId, setUnavailableId] = useState<string | null>(null);
64
- const [position, setPosition] = useState({ left: 12, top: 12, above: false });
52
+ const [position, setPosition] = useState({ left: 12, top: 12, maxHeight: 0 });
65
53
  const panelRef = useRef<HTMLDivElement | null>(null);
54
+ const headerRef = useRef<HTMLDivElement | null>(null);
55
+ const listRef = useRef<HTMLDivElement | null>(null);
66
56
  const noteRefs = useRef(new Map<string, HTMLTextAreaElement>());
57
+ const portalStyle = usePortalTokenStyle(triggerRef.current);
58
+ const revealNote = (id: string) => {
59
+ const row = listRef.current?.querySelector<HTMLElement>(
60
+ `[data-og-annotation-id="${cssEscapeAttribute(id)}"]`,
61
+ );
62
+ scrollAnnotationRowIntoList(listRef.current, row);
63
+ noteRefs.current.get(id)?.focus();
64
+ };
67
65
 
68
- useLayoutEffect(() => {
69
- if (!focusAnnotationId) return;
70
- const note = noteRefs.current.get(focusAnnotationId);
71
- note?.focus();
72
- if (note && document.activeElement === note) onFocusConsumed?.();
73
- }, [focusAnnotationId, onFocusConsumed]);
66
+ const commitNotes = (fromId: string) => {
67
+ const incomplete = annotations.filter((annotation) => {
68
+ const live = noteRefs.current.get(annotation.id);
69
+ return (live?.value ?? annotation.note).trim().length === 0;
70
+ });
71
+ if (incomplete.length === 0) {
72
+ onDismiss(true);
73
+ return;
74
+ }
75
+ const fromIndex = annotations.findIndex((annotation) => annotation.id === fromId);
76
+ const next =
77
+ incomplete.find(
78
+ (item) => annotations.findIndex((annotation) => annotation.id === item.id) > fromIndex,
79
+ ) ?? incomplete[0]!;
80
+ revealNote(next.id);
81
+ };
74
82
 
75
- useEffect(() => {
83
+ useLayoutEffect(() => {
76
84
  const updatePosition = () => {
77
85
  const rect = triggerRef.current?.getBoundingClientRect();
78
86
  if (!rect) return;
79
- const panelWidth = Math.min(400, window.innerWidth - 24);
80
- const left = Math.min(
81
- Math.max(12, rect.left),
82
- Math.max(12, window.innerWidth - panelWidth - 12),
87
+ const contentHeight =
88
+ (headerRef.current?.offsetHeight ?? 44) + (listRef.current?.scrollHeight ?? 200);
89
+ setPosition(
90
+ clampAnnotationDialogPlacement({
91
+ triggerLeft: rect.left,
92
+ triggerTop: rect.top,
93
+ triggerBottom: rect.bottom,
94
+ contentHeight,
95
+ viewportWidth: window.innerWidth,
96
+ viewportHeight: window.innerHeight,
97
+ panelWidth: ANNOTATION_REVIEW_DIALOG_WIDTH_PX,
98
+ }),
83
99
  );
84
- const above = rect.top > Math.min(360, window.innerHeight * 0.55);
85
- setPosition({ left, top: above ? rect.top - 8 : rect.bottom + 8, above });
86
100
  };
101
+ updatePosition();
102
+ window.addEventListener("resize", updatePosition);
103
+ window.addEventListener("scroll", updatePosition, true);
104
+ return () => {
105
+ window.removeEventListener("resize", updatePosition);
106
+ window.removeEventListener("scroll", updatePosition, true);
107
+ };
108
+ }, [annotations, triggerRef]);
109
+
110
+ useLayoutEffect(() => {
111
+ if (!focusAnnotationId || position.maxHeight <= 0) return;
112
+ revealNote(focusAnnotationId);
113
+ const note = noteRefs.current.get(focusAnnotationId);
114
+ if (note && document.activeElement === note) onFocusConsumed?.();
115
+ }, [focusAnnotationId, onFocusConsumed, annotations.length, position.maxHeight]);
116
+
117
+ useEffect(() => {
87
118
  const onKeyDown = (event: KeyboardEvent) => {
88
- if (event.key === "Escape") onDismiss(true);
119
+ if (event.key === "Escape") {
120
+ event.stopPropagation();
121
+ onDismiss(true);
122
+ return;
123
+ }
124
+ if (event.key !== "Tab" || !panelRef.current) return;
125
+ const focusable = focusableElements(panelRef.current);
126
+ if (focusable.length === 0) return;
127
+ const first = focusable[0]!;
128
+ const last = focusable[focusable.length - 1]!;
129
+ if (event.shiftKey && document.activeElement === first) {
130
+ event.preventDefault();
131
+ last.focus();
132
+ } else if (!event.shiftKey && document.activeElement === last) {
133
+ event.preventDefault();
134
+ first.focus();
135
+ }
89
136
  };
90
137
  const onPointerDown = (event: PointerEvent) => {
91
138
  if (!(event.target instanceof Node)) return;
@@ -94,9 +141,6 @@ export function TimelineAnnotationsDialog({
94
141
  }
95
142
  onDismiss(false);
96
143
  };
97
- updatePosition();
98
- window.addEventListener("resize", updatePosition);
99
- window.addEventListener("scroll", updatePosition, true);
100
144
  document.addEventListener("keydown", onKeyDown);
101
145
  document.addEventListener("pointerdown", onPointerDown);
102
146
  if (!focusAnnotationId) {
@@ -105,8 +149,6 @@ export function TimelineAnnotationsDialog({
105
149
  }, 0);
106
150
  }
107
151
  return () => {
108
- window.removeEventListener("resize", updatePosition);
109
- window.removeEventListener("scroll", updatePosition, true);
110
152
  document.removeEventListener("keydown", onKeyDown);
111
153
  document.removeEventListener("pointerdown", onPointerDown);
112
154
  };
@@ -118,92 +160,94 @@ export function TimelineAnnotationsDialog({
118
160
  ref={panelRef}
119
161
  role="dialog"
120
162
  tabIndex={-1}
163
+ data-og-annotation-review=""
121
164
  style={{
122
165
  left: position.left,
123
166
  top: position.top,
124
- transform: position.above ? "translateY(-100%)" : undefined,
167
+ maxHeight: position.maxHeight,
168
+ ...portalStyle,
125
169
  }}
126
- className="og-root fixed z-[75] box-border max-h-[min(32rem,70vh)] w-[min(25rem,calc(100vw-1.5rem))] overflow-y-auto rounded-og-lg border border-og-border bg-og-surface-1 p-3 text-og-fg shadow-xl outline-hidden"
127
- aria-label={editable ? "Review timeline annotations" : "Timeline annotations"}
170
+ className="og-root fixed z-[75] box-border flex w-[min(25rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-og-lg border border-og-border bg-og-surface-1 text-og-fg shadow-xl outline-hidden"
171
+ aria-label={editable ? "Edit quoted notes" : "Quoted notes"}
128
172
  >
129
- <div className="mb-2 flex items-center justify-between gap-3">
130
- <div>
131
- <p className="text-og-sm font-semibold">{countLabel}</p>
132
- <p className="text-og-xs text-og-fg-subtle">
133
- {editable ? "Add a note for each quoted source." : "Sent with this message."}
134
- </p>
135
- </div>
173
+ <div
174
+ ref={headerRef}
175
+ data-og-annotation-review-header=""
176
+ className="flex shrink-0 items-start justify-between gap-3 px-3 pt-3 pb-2"
177
+ >
178
+ <p className="min-w-0 pt-0.5 text-og-sm font-medium text-og-fg-muted">{countLabel}</p>
136
179
  <button
137
180
  type="button"
138
- className="rounded-md border-0 bg-transparent px-2 py-1 text-og-xs text-og-fg outline-hidden hover:bg-og-surface-2 focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-[44px]"
181
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-og-fg-muted outline-hidden hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:size-11"
182
+ aria-label="Close"
139
183
  onClick={() => onDismiss(true)}
140
184
  >
141
- Close
185
+ <XIcon className="size-3.5" aria-hidden="true" />
142
186
  </button>
143
187
  </div>
144
- <div className="grid gap-2.5">
145
- {annotations.map((annotation, index) => (
146
- <section
147
- key={annotation.id}
148
- className="rounded-og-md border border-og-border bg-og-surface-2/60 p-2.5"
149
- aria-label={`Annotation ${index + 1}`}
150
- >
151
- <div className="flex items-center justify-between gap-2">
152
- <button
153
- type="button"
154
- className="truncate rounded border-0 bg-transparent text-left text-og-xs font-medium text-og-fg-muted underline-offset-2 outline-hidden hover:text-og-fg hover:underline focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-[44px]"
155
- onClick={() => {
156
- const revealed =
157
- onRevealSource?.(annotation.source) ?? revealLoadedSource(annotation.source);
158
- setUnavailableId(revealed ? null : annotation.id);
159
- }}
160
- >
161
- {sourceLabel(annotation)} · view source
162
- </button>
163
- {editable && onRemove ? (
164
- <button
165
- type="button"
166
- className="shrink-0 rounded border-0 bg-transparent p-1 text-og-fg-subtle outline-hidden hover:bg-og-surface-1 hover:text-og-status-failed focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:size-[44px]"
167
- aria-label={`Remove annotation ${index + 1}`}
168
- onClick={() => onRemove(annotation.id)}
169
- >
170
- <span aria-hidden="true" className="text-og-md leading-none">
171
- ×
172
- </span>
173
- </button>
174
- ) : null}
175
- </div>
176
- {unavailableId === annotation.id ? (
177
- <p role="status" className="mt-1 text-og-xs text-og-status-waiting">
178
- Source is outside the loaded timeline window.
179
- </p>
180
- ) : null}
181
- <blockquote className="mt-2 max-h-28 overflow-auto whitespace-pre-wrap border-l-2 border-og-border pl-2 text-og-sm leading-5 text-og-fg-muted">
182
- {annotation.quote}
183
- </blockquote>
184
- {editable && onUpdate ? (
185
- <label className="mt-2 block text-og-xs font-medium text-og-fg-muted">
186
- Note
187
- <textarea
188
- ref={(node) => {
189
- if (node) noteRefs.current.set(annotation.id, node);
190
- else noteRefs.current.delete(annotation.id);
191
- }}
192
- value={annotation.note}
193
- rows={2}
194
- maxLength={2048}
195
- placeholder="What should the agent do with this?"
196
- className="mt-1 w-full resize-y rounded-og-sm border border-og-border bg-og-surface-1 px-2.5 py-2 text-og-sm leading-5 text-og-fg outline-hidden placeholder:text-og-fg-subtle focus:border-og-accent focus:ring-1 focus:ring-og-accent"
197
- onInput={(event) => onUpdate(annotation.id, event.currentTarget.value)}
198
- />
199
- </label>
200
- ) : annotation.note ? (
201
- <p className="mt-2 whitespace-pre-wrap text-og-sm leading-5 text-og-fg">
202
- {annotation.note}
203
- </p>
204
- ) : null}
205
- </section>
206
- ))}
188
+ <div
189
+ ref={listRef}
190
+ data-og-annotation-review-list=""
191
+ className="grid min-h-0 flex-1 gap-2.5 overflow-x-hidden overflow-y-auto overscroll-contain px-3 pb-3"
192
+ >
193
+ {annotations.map((annotation, index) => {
194
+ const ordinal = annotationDisplayOrdinal(annotation, index);
195
+ return (
196
+ <section
197
+ key={annotation.id}
198
+ data-og-annotation-id={annotation.id}
199
+ aria-label={`Annotation ${ordinal}`}
200
+ >
201
+ <AnnotationAccentRow>
202
+ <div className="flex items-start gap-1">
203
+ <div className="min-w-0 flex-1">
204
+ <p className="mb-0.5 text-og-xs font-medium tabular-nums text-og-fg-subtle">
205
+ Annotation {ordinal}
206
+ </p>
207
+ <AnnotationQuoteSourceButton
208
+ annotation={annotation}
209
+ lines={2}
210
+ onRevealSource={onRevealSource}
211
+ onUnavailable={setUnavailableId}
212
+ />
213
+ </div>
214
+ {editable && onRemove ? (
215
+ <button
216
+ type="button"
217
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-og-fg-subtle outline-hidden hover:bg-og-surface-1 hover:text-og-status-failed focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:size-11"
218
+ aria-label={`Remove annotation ${ordinal}`}
219
+ onClick={() => onRemove(annotation.id)}
220
+ >
221
+ <XIcon className="size-3.5" aria-hidden="true" />
222
+ </button>
223
+ ) : null}
224
+ </div>
225
+ {unavailableId === annotation.id ? (
226
+ <p role="status" className="text-og-xs text-og-status-waiting">
227
+ Source is outside the loaded timeline window.
228
+ </p>
229
+ ) : null}
230
+ {editable && onUpdate ? (
231
+ <AnnotationNoteField
232
+ annotation={annotation}
233
+ inputRef={(node) => {
234
+ if (node) noteRefs.current.set(annotation.id, node);
235
+ else noteRefs.current.delete(annotation.id);
236
+ }}
237
+ onUpdate={onUpdate}
238
+ onCommit={() => commitNotes(annotation.id)}
239
+ />
240
+ ) : (
241
+ <AnnotationNotePreview
242
+ note={annotation.note}
243
+ annotationId={annotation.id}
244
+ ordinal={ordinal}
245
+ />
246
+ )}
247
+ </AnnotationAccentRow>
248
+ </section>
249
+ );
250
+ })}
207
251
  </div>
208
252
  </div>,
209
253
  document.body,
@@ -1,15 +1,114 @@
1
- import type {
2
- DraftTimelineAnnotation,
3
- TimelineAnnotation,
4
- TimelineAnnotationSource,
5
- } from "@opengeni/sdk";
6
- import { Suspense, lazy, useRef, useState } from "react";
1
+ import type { TimelineAnnotationSource } from "@opengeni/sdk";
2
+ import { QuoteIcon, XIcon } from "lucide-react";
3
+ import {
4
+ Suspense,
5
+ lazy,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ type ReactNode,
11
+ type RefObject,
12
+ } from "react";
7
13
  import { cn } from "../lib/cn";
14
+ import {
15
+ AnnotationAccentRow,
16
+ AnnotationNotePreview,
17
+ AnnotationQuoteSourceButton,
18
+ type TimelineAnnotationLike,
19
+ } from "./timeline-annotation-chrome";
20
+ import { ANNOTATION_CARD_STACK_SCROLL_AT } from "./timeline-annotation-layout";
21
+ import {
22
+ TimelineAnnotationSourceRootContext,
23
+ useTimelineAnnotationSourceRoot,
24
+ } from "./timeline-annotation-reveal-context";
25
+ import {
26
+ annotationDisplayOrdinal,
27
+ annotationHasNote,
28
+ resolveAnnotationRevealRoot,
29
+ } from "./timeline-annotation-shared";
8
30
 
9
- export type TimelineAnnotationLike = DraftTimelineAnnotation | TimelineAnnotation;
31
+ export type { TimelineAnnotationLike } from "./timeline-annotation-chrome";
10
32
 
11
33
  const TimelineAnnotationsDialog = lazy(() => import("./timeline-annotations-dialog"));
12
34
 
35
+ function AnnotationSourceRootBridge({
36
+ triggerRef,
37
+ children,
38
+ }: {
39
+ triggerRef: RefObject<HTMLElement | null>;
40
+ children: ReactNode;
41
+ }) {
42
+ const inheritedRoot = useTimelineAnnotationSourceRoot();
43
+ const conversationRootRef = useRef<HTMLElement | null>(null);
44
+ if (!inheritedRoot) {
45
+ conversationRootRef.current = resolveAnnotationRevealRoot(triggerRef.current);
46
+ }
47
+ useLayoutEffect(() => {
48
+ if (inheritedRoot) return;
49
+ conversationRootRef.current = resolveAnnotationRevealRoot(triggerRef.current);
50
+ });
51
+ if (inheritedRoot) return children;
52
+ return (
53
+ <TimelineAnnotationSourceRootContext.Provider value={conversationRootRef}>
54
+ {children}
55
+ </TimelineAnnotationSourceRootContext.Provider>
56
+ );
57
+ }
58
+
59
+ export function TimelineAnnotationCards({
60
+ annotations,
61
+ onRevealSource,
62
+ className,
63
+ }: {
64
+ annotations: readonly TimelineAnnotationLike[];
65
+ onRevealSource?: ((source: TimelineAnnotationSource) => boolean) | undefined;
66
+ className?: string | undefined;
67
+ }) {
68
+ const [unavailableId, setUnavailableId] = useState<string | null>(null);
69
+ if (annotations.length === 0) return null;
70
+ return (
71
+ <div
72
+ data-og-annotation-cards=""
73
+ className={cn(
74
+ "grid gap-2",
75
+ annotations.length >= ANNOTATION_CARD_STACK_SCROLL_AT &&
76
+ "max-h-[min(28rem,55vh)] overflow-x-hidden overflow-y-auto overscroll-contain pr-1",
77
+ className,
78
+ )}
79
+ >
80
+ {annotations.map((annotation, index) => {
81
+ const ordinal = annotationDisplayOrdinal(annotation, index);
82
+ return (
83
+ <section key={annotation.id} aria-label={`Annotation ${ordinal}`}>
84
+ <AnnotationAccentRow>
85
+ <p className="mb-0.5 text-og-xs font-medium tabular-nums text-og-fg-subtle">
86
+ Annotation {ordinal}
87
+ </p>
88
+ <AnnotationQuoteSourceButton
89
+ annotation={annotation}
90
+ lines={2}
91
+ onRevealSource={onRevealSource}
92
+ onUnavailable={setUnavailableId}
93
+ />
94
+ {unavailableId === annotation.id ? (
95
+ <p role="status" className="text-og-xs text-og-status-waiting">
96
+ Source is outside the loaded timeline window.
97
+ </p>
98
+ ) : null}
99
+ <AnnotationNotePreview
100
+ note={annotation.note}
101
+ annotationId={annotation.id}
102
+ ordinal={ordinal}
103
+ />
104
+ </AnnotationAccentRow>
105
+ </section>
106
+ );
107
+ })}
108
+ </div>
109
+ );
110
+ }
111
+
13
112
  export function TimelineAnnotationsChip({
14
113
  annotations,
15
114
  editable = false,
@@ -29,55 +128,89 @@ export function TimelineAnnotationsChip({
29
128
  onRevealSource?: ((source: TimelineAnnotationSource) => boolean) | undefined;
30
129
  className?: string | undefined;
31
130
  }) {
32
- const [open, setOpen] = useState(false);
33
- const triggerRef = useRef<HTMLButtonElement | null>(null);
34
-
35
- if (annotations.length === 0) return null;
36
131
  const focusRequested = Boolean(
37
132
  focusAnnotationId && annotations.some((item) => item.id === focusAnnotationId),
38
133
  );
39
- const visible = open || focusRequested;
134
+ const [open, setOpen] = useState(focusRequested);
135
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
136
+ const openedFocusId = useRef<string | null>(focusRequested ? (focusAnnotationId ?? null) : null);
137
+
138
+ useEffect(() => {
139
+ if (!focusRequested || !focusAnnotationId) {
140
+ if (!focusRequested) openedFocusId.current = null;
141
+ return;
142
+ }
143
+ if (openedFocusId.current === focusAnnotationId) return;
144
+ openedFocusId.current = focusAnnotationId;
145
+ setOpen(true);
146
+ }, [focusAnnotationId, focusRequested]);
147
+
148
+ if (annotations.length === 0) return null;
40
149
  const countLabel = `${annotations.length} ${annotations.length === 1 ? "annotation" : "annotations"}`;
150
+ const incomplete = annotations.some((annotation) => !annotationHasNote(annotation.note));
151
+ const canClear = Boolean(editable && onRemove);
152
+
153
+ const dismiss = (restoreFocus: boolean) => {
154
+ setOpen(false);
155
+ onFocusConsumed?.();
156
+ if (restoreFocus) triggerRef.current?.focus();
157
+ };
41
158
 
42
159
  return (
43
- <>
44
- <button
45
- ref={triggerRef}
46
- type="button"
160
+ <span className={cn("inline-flex max-w-full items-center", className)}>
161
+ <span
47
162
  className={cn(
48
- "inline-flex min-h-8 items-center gap-1.5 rounded-full border border-og-border bg-og-surface-1 px-2.5 py-1 text-og-sm font-medium text-og-fg-muted outline-hidden transition hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-[44px]",
49
- className,
163
+ "inline-flex max-w-full items-center rounded-full border bg-og-surface-1 text-og-fg-muted",
164
+ incomplete ? "border-og-accent/45 bg-og-accent-soft/50" : "border-og-border",
50
165
  )}
51
- aria-label={`Review ${countLabel}`}
52
- aria-haspopup="dialog"
53
- aria-expanded={visible}
54
- onClick={() => setOpen((current) => !current)}
55
166
  >
56
- {countLabel}
57
- </button>
58
- {visible ? (
59
- <Suspense fallback={null}>
60
- <TimelineAnnotationsDialog
61
- annotations={annotations}
62
- editable={editable}
63
- focusAnnotationId={focusAnnotationId}
64
- onFocusConsumed={() => {
65
- setOpen(true);
66
- onFocusConsumed?.();
67
- }}
68
- onUpdate={onUpdate}
69
- onRemove={onRemove}
70
- onRevealSource={onRevealSource}
71
- triggerRef={triggerRef}
72
- countLabel={countLabel}
73
- onDismiss={(restoreFocus) => {
74
- setOpen(false);
75
- if (restoreFocus) triggerRef.current?.focus();
167
+ <button
168
+ ref={triggerRef}
169
+ type="button"
170
+ className={cn(
171
+ "inline-flex min-h-8 min-w-0 max-w-full items-center gap-1.5 rounded-full border-0 bg-transparent py-1 text-og-sm font-medium outline-hidden transition hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-[44px]",
172
+ canClear ? "pr-1 pl-2.5" : "px-2.5",
173
+ )}
174
+ aria-label={`Review ${countLabel}`}
175
+ aria-haspopup="dialog"
176
+ aria-expanded={open}
177
+ onClick={() => setOpen((current) => !current)}
178
+ >
179
+ <QuoteIcon className="size-3.5 shrink-0 text-og-accent" aria-hidden="true" />
180
+ <span className="min-w-0 truncate">{countLabel}</span>
181
+ </button>
182
+ {canClear ? (
183
+ <button
184
+ type="button"
185
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border-0 bg-transparent text-og-fg-subtle outline-hidden hover:text-og-status-failed focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:size-11"
186
+ aria-label="Remove all annotations"
187
+ onClick={() => {
188
+ for (const annotation of annotations) onRemove?.(annotation.id);
76
189
  }}
77
- />
78
- </Suspense>
190
+ >
191
+ <XIcon className="size-3.5" aria-hidden="true" />
192
+ </button>
193
+ ) : null}
194
+ </span>
195
+ {open ? (
196
+ <AnnotationSourceRootBridge triggerRef={triggerRef}>
197
+ <Suspense fallback={null}>
198
+ <TimelineAnnotationsDialog
199
+ annotations={annotations}
200
+ editable={editable}
201
+ focusAnnotationId={focusAnnotationId}
202
+ onFocusConsumed={onFocusConsumed}
203
+ onUpdate={onUpdate}
204
+ onRemove={onRemove}
205
+ onRevealSource={onRevealSource}
206
+ triggerRef={triggerRef}
207
+ countLabel={countLabel}
208
+ onDismiss={dismiss}
209
+ />
210
+ </Suspense>
211
+ </AnnotationSourceRootBridge>
79
212
  ) : null}
80
- </>
213
+ </span>
81
214
  );
82
215
  }
83
216