@opengeni/react 0.6.2 → 0.8.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 (45) hide show
  1. package/README.md +17 -6
  2. package/dist/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
  3. package/dist/chunk-5C7RGAWA.js.map +1 -0
  4. package/dist/index.d.ts +45 -9
  5. package/dist/index.js +1385 -732
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BD6h9P_s.d.ts → machines-BeZ3bD3t.d.ts} +2 -2
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +2 -2
  11. package/src/client.ts +1 -0
  12. package/src/components/chat-composer.tsx +127 -60
  13. package/src/components/code-editor.tsx +15 -15
  14. package/src/components/desktop-viewer.tsx +40 -36
  15. package/src/components/diff-view.tsx +22 -22
  16. package/src/components/enrollment-consent.tsx +13 -13
  17. package/src/components/file-browser.tsx +74 -67
  18. package/src/components/fleet-tile.tsx +3 -3
  19. package/src/components/machine-card.tsx +6 -6
  20. package/src/components/machine-status-pill.tsx +3 -3
  21. package/src/components/machines-dashboard.tsx +28 -14
  22. package/src/components/markdown.tsx +6 -6
  23. package/src/components/message-timeline.tsx +207 -12
  24. package/src/components/pierre-diff.tsx +9 -10
  25. package/src/components/pierre-file.tsx +8 -8
  26. package/src/components/sandbox-files.tsx +37 -40
  27. package/src/components/sandbox-terminal.tsx +9 -11
  28. package/src/components/session-status.tsx +2 -2
  29. package/src/components/workspace-dock.tsx +153 -49
  30. package/src/hooks/use-file-attachments.ts +45 -15
  31. package/src/hooks/use-session-events.ts +283 -8
  32. package/src/index.ts +2 -2
  33. package/src/lib/format.ts +32 -0
  34. package/src/lib/xterm-theme.ts +8 -11
  35. package/src/timeline/activity-rail.tsx +5 -2
  36. package/src/timeline/entrance.tsx +30 -0
  37. package/src/timeline/parsers.ts +104 -0
  38. package/src/timeline/projection.ts +22 -5
  39. package/src/timeline/screenshot-lightbox.tsx +1 -1
  40. package/src/timeline/shared.tsx +4 -1
  41. package/src/timeline/tool-diff.tsx +1 -1
  42. package/src/timeline/tool-renderers.tsx +46 -7
  43. package/src/timeline/turn-summary.tsx +24 -4
  44. package/styles/tokens.css +8 -0
  45. package/dist/chunk-DEW2ZNF2.js.map +0 -1
@@ -181,7 +181,7 @@ export function SandboxTerminal({
181
181
  convertEol: true,
182
182
  disableStdin: !interactive,
183
183
  cursorBlink: interactive,
184
- fontFamily: fontFamily ?? "var(--og-font-mono, var(--font-mono, monospace))",
184
+ fontFamily: fontFamily ?? "var(--og-font-mono)",
185
185
  fontSize: fontSize ?? 13,
186
186
  lineHeight: 1.25,
187
187
  // A little breathing room from the panel edge so output isn't flush to
@@ -318,23 +318,21 @@ export function SandboxTerminal({
318
318
  return (
319
319
  <div className={cn("relative flex h-full w-full flex-col overflow-hidden", className)}>
320
320
  {showHeader && (
321
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-2 py-1 text-[11px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
321
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border px-2 py-1 text-og-xs text-og-fg-subtle">
322
322
  <span className="flex min-w-0 items-center gap-1.5">
323
323
  <span
324
324
  className={cn(
325
325
  "size-1.5 shrink-0 rounded-full",
326
- result.running
327
- ? "bg-[color:var(--og-color-status-running,var(--color-status-running,#d29922))]"
328
- : "bg-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
326
+ result.running ? "bg-og-status-running" : "bg-og-fg-subtle",
329
327
  )}
330
328
  />
331
- <span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))]">
329
+ <span className="truncate font-og-mono">
332
330
  pty: {shell ?? "shell"}
333
331
  </span>
334
332
  </span>
335
333
  <span className="flex shrink-0 items-center gap-2">
336
334
  {!interactive && (
337
- <span className="rounded-[var(--og-radius-sm,4px)] bg-[color:var(--og-color-surface-2,var(--color-surface-2,#161616))] px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
335
+ <span className="rounded-og-sm bg-og-surface-2 px-1.5 py-0.5 text-og-xs uppercase tracking-wide">
338
336
  read-only
339
337
  </span>
340
338
  )}
@@ -344,7 +342,7 @@ export function SandboxTerminal({
344
342
  termRef.current?.clear();
345
343
  writtenRef.current = new Set();
346
344
  }}
347
- className="rounded-[var(--og-radius-sm,4px)] px-1.5 py-0.5 text-[10px] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
345
+ className="rounded-og-sm px-1.5 py-0.5 text-og-xs hover:text-og-fg pointer-coarse:min-h-10"
348
346
  >
349
347
  Clear
350
348
  </button>
@@ -355,7 +353,7 @@ export function SandboxTerminal({
355
353
  with the terminal surface (capture so they win even though xterm's own
356
354
  listeners stop propagation). The host warms the box for pty-ws here. */}
357
355
  <div
358
- className="relative min-h-0 flex-1 bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))] px-2 py-1.5"
356
+ className="relative min-h-0 flex-1 bg-og-bg px-2 py-1.5"
359
357
  onPointerDownCapture={onActivate}
360
358
  onFocusCapture={onActivate}
361
359
  >
@@ -379,7 +377,7 @@ const XTERM_BASE_CSS = `
379
377
  .xterm.focus,.xterm:focus{outline:none}
380
378
  .xterm .xterm-helpers{position:absolute;top:0;z-index:5}
381
379
  .xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}
382
- .xterm .composition-view{background:#000;color:#FFF;display:none;position:absolute;white-space:nowrap;z-index:1}
380
+ .xterm .composition-view{background:var(--og-color-bg);color:var(--og-color-fg);display:none;position:absolute;white-space:nowrap;z-index:1}
383
381
  .xterm .composition-view.active{display:block}
384
382
  .xterm .xterm-viewport{background-color:transparent;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}
385
383
  .xterm .xterm-screen{position:relative}
@@ -418,7 +416,7 @@ function ensureXtermBaseCss(): void {
418
416
 
419
417
  function TerminalPlaceholder() {
420
418
  return (
421
- <div className="absolute inset-0 flex items-center justify-center text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
419
+ <div className="absolute inset-0 flex items-center justify-center text-og-sm text-og-fg-subtle">
422
420
  Loading terminal…
423
421
  </div>
424
422
  );
@@ -30,7 +30,7 @@ export const SESSION_STATUS_META: Record<SessionStatusValue, SessionStatusMeta>
30
30
  pulse: false,
31
31
  },
32
32
  requires_action: {
33
- label: "Needs you",
33
+ label: "Waiting on you",
34
34
  dotClassName: "bg-og-status-waiting",
35
35
  badgeClassName: "text-og-status-waiting border-og-status-waiting/35 bg-og-status-waiting/10",
36
36
  pulse: true,
@@ -65,7 +65,7 @@ export function SessionStatus({ status, label, size = "md", className }: Session
65
65
  data-status={status}
66
66
  className={cn(
67
67
  "og-root inline-flex shrink-0 items-center rounded-full border font-medium",
68
- size === "sm" ? "gap-1 px-1.5 py-px text-[10px]" : "gap-1.5 px-2 py-0.5 text-xs",
68
+ size === "sm" ? "gap-1 px-1.5 py-px text-og-xs" : "gap-1.5 px-2 py-0.5 text-xs",
69
69
  meta.badgeClassName,
70
70
  className,
71
71
  )}
@@ -4,6 +4,7 @@ import {
4
4
  Maximize2Icon,
5
5
  Minimize2Icon,
6
6
  PanelRightCloseIcon,
7
+ XIcon,
7
8
  } from "lucide-react";
8
9
  import {
9
10
  Group,
@@ -49,9 +50,43 @@ export type WorkspaceDockProps = {
49
50
  * restore button returns). Layout persists via `useDefaultLayout` keyed on
50
51
  * `autoSaveId`. Maximize is a mode ABOVE the Group (a `fixed inset-0` overlay) —
51
52
  * pushing a Panel to ~100% still fights min sizes and leaves a chat sliver.
53
+ *
54
+ * Below {@link DOCK_OVERLAY_BREAKPOINT} the side-by-side split can't work on a
55
+ * phone-width viewport, so the resizable panels are dropped entirely: the
56
+ * primary pane goes full-width and the dock becomes a full-screen overlay driven
57
+ * by the same `collapsed` / `onCollapsedChange` contract (collapsed → hidden).
58
+ * No drag splitter renders below the breakpoint.
52
59
  */
53
60
  const useDockLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
54
61
 
62
+ /**
63
+ * The dock stops being a side column at this width and becomes a full-screen
64
+ * overlay — matches the app's rail-drawer breakpoint (the single `isMobile`
65
+ * source). The package can't read app context, so it detects the width itself.
66
+ */
67
+ const DOCK_OVERLAY_BREAKPOINT = 1024;
68
+
69
+ /** SSR-safe `(max-width: …)` match; false until mounted, then live. */
70
+ function useIsNarrow(maxWidth: number): boolean {
71
+ const [narrow, setNarrow] = useState(false);
72
+ useDockLayoutEffect(() => {
73
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
74
+ return;
75
+ }
76
+ const mql = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
77
+ const update = () => setNarrow(mql.matches);
78
+ update();
79
+ if (typeof mql.addEventListener === "function") {
80
+ mql.addEventListener("change", update);
81
+ return () => mql.removeEventListener("change", update);
82
+ }
83
+ // Legacy Safari.
84
+ mql.addListener(update);
85
+ return () => mql.removeListener(update);
86
+ }, [maxWidth]);
87
+ return narrow;
88
+ }
89
+
55
90
  export function WorkspaceDock({
56
91
  primary,
57
92
  tabs,
@@ -65,11 +100,18 @@ export function WorkspaceDock({
65
100
  maxSize = 70,
66
101
  className,
67
102
  }: WorkspaceDockProps) {
103
+ const narrow = useIsNarrow(DOCK_OVERLAY_BREAKPOINT);
68
104
  const dockPanelRef = usePanelRef();
69
105
  const [internalCollapsed, setInternalCollapsed] = useState(false);
70
106
  const [maximized, setMaximized] = useState(false);
71
107
  const [internalTab, setInternalTab] = useState(tabs[0]?.id ?? "");
72
108
  const collapsed = collapsedProp ?? internalCollapsed;
109
+ // When the host supplies `collapsed` it owns the open/close affordance (e.g.
110
+ // the app header's panel toggle) — the dock must not offer a SECOND
111
+ // open/close control (duplicate buttons with near-identical icons read as a
112
+ // bug). Standalone (uncontrolled) usage keeps the built-in collapse button
113
+ // and the thin re-open rail as its only affordances.
114
+ const hostControlled = collapsedProp !== undefined;
73
115
 
74
116
  // Persisted layout (width split) keyed by autoSaveId.
75
117
  const { defaultLayout, onLayoutChanged } = useDefaultLayout({
@@ -117,16 +159,6 @@ export function WorkspaceDock({
117
159
  // rebuild tab JSX frequently; only id changes can invalidate the active tab.
118
160
  }, [tabIds, firstTabId, current, setTab]);
119
161
 
120
- // Esc restores from maximize.
121
- useEffect(() => {
122
- if (!maximized) return;
123
- const onKey = (event: KeyboardEvent) => {
124
- if (event.key === "Escape") setMaximized(false);
125
- };
126
- window.addEventListener("keydown", onKey);
127
- return () => window.removeEventListener("keydown", onKey);
128
- }, [maximized]);
129
-
130
162
  const collapse = useCallback(() => {
131
163
  dockPanelRef.current?.collapse();
132
164
  setCollapsed(true);
@@ -136,14 +168,74 @@ export function WorkspaceDock({
136
168
  setCollapsed(false);
137
169
  }, [dockPanelRef, setCollapsed]);
138
170
 
171
+ // Esc restores from maximize (desktop) and closes the mobile overlay.
172
+ useEffect(() => {
173
+ const overlayOpen = maximized || (narrow && !collapsed);
174
+ if (!overlayOpen) return;
175
+ const onKey = (event: KeyboardEvent) => {
176
+ if (event.key !== "Escape") return;
177
+ if (maximized) setMaximized(false);
178
+ else collapse();
179
+ };
180
+ window.addEventListener("keydown", onKey);
181
+ return () => window.removeEventListener("keydown", onKey);
182
+ }, [maximized, narrow, collapsed, collapse]);
183
+
184
+ // Below the breakpoint the dock is a full-screen overlay, not a resizable
185
+ // column: primary goes full-width and no splitter ever mounts. The overlay is
186
+ // driven by the same `collapsed` contract (collapsed → hidden).
187
+ if (narrow) {
188
+ return (
189
+ <div className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
190
+ <div className="min-h-0 min-w-0 flex-1">{primary}</div>
191
+ {!collapsed && (
192
+ <div
193
+ role="dialog"
194
+ aria-modal="true"
195
+ aria-label="Workspace"
196
+ className="fixed inset-0 z-40 flex flex-col bg-og-bg"
197
+ style={{
198
+ paddingTop: "env(safe-area-inset-top)",
199
+ paddingBottom: "env(safe-area-inset-bottom)",
200
+ }}
201
+ >
202
+ <DockChrome
203
+ tabs={tabs}
204
+ current={current}
205
+ onTab={setTab}
206
+ controls={
207
+ <ChromeButton onClick={collapse} title="Close workspace" label="Close workspace">
208
+ <XIcon className="size-4" />
209
+ </ChromeButton>
210
+ }
211
+ />
212
+ </div>
213
+ )}
214
+ </div>
215
+ );
216
+ }
217
+
139
218
  const dockChrome = (
140
219
  <DockChrome
141
220
  tabs={tabs}
142
221
  current={current}
143
222
  onTab={setTab}
144
- maximized={maximized}
145
- onToggleMaximize={() => setMaximized((m) => !m)}
146
- onCollapse={maximized ? () => setMaximized(false) : collapse}
223
+ controls={
224
+ <>
225
+ <ChromeButton
226
+ onClick={() => setMaximized((m) => !m)}
227
+ title={maximized ? "Restore (Esc)" : "Maximize"}
228
+ label={maximized ? "Restore dock" : "Maximize dock"}
229
+ >
230
+ {maximized ? <Minimize2Icon className="size-3.5" /> : <Maximize2Icon className="size-3.5" />}
231
+ </ChromeButton>
232
+ {hostControlled ? null : (
233
+ <ChromeButton onClick={collapse} title="Collapse" label="Collapse dock">
234
+ <PanelRightCloseIcon className="size-3.5" />
235
+ </ChromeButton>
236
+ )}
237
+ </>
238
+ }
147
239
  />
148
240
  );
149
241
 
@@ -161,7 +253,7 @@ export function WorkspaceDock({
161
253
 
162
254
  {!collapsed && (
163
255
  <Separator className="group relative w-1.5 shrink-0 outline-none">
164
- <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-[color:var(--og-color-border,var(--color-border,#2a2a2a))] transition-colors group-hover:bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))] group-data-[separator-state=dragging]:bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]" />
256
+ <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-og-border transition-colors group-hover:bg-og-accent group-data-[separator-state=dragging]:bg-og-accent" />
165
257
  </Separator>
166
258
  )}
167
259
 
@@ -186,20 +278,21 @@ export function WorkspaceDock({
186
278
  {/* Hidden behind the overlay while maximized (avoids double-mounting
187
279
  the surfaces). */}
188
280
  {!collapsed && !maximized && (
189
- <div className="flex h-full min-h-0 min-w-0 flex-col border-l border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))]">
281
+ <div className="flex h-full min-h-0 min-w-0 flex-col border-l border-og-border bg-og-bg">
190
282
  {dockChrome}
191
283
  </div>
192
284
  )}
193
285
  </Panel>
194
286
  </Group>
195
287
 
196
- {/* Collapsed rail: a thin tab on the right edge that re-opens the dock. */}
197
- {collapsed && !maximized && (
288
+ {/* Collapsed rail: the standalone fallback re-open affordance. Hidden
289
+ when the host controls collapse — its own toggle is the one way in. */}
290
+ {collapsed && !maximized && !hostControlled && (
198
291
  <button
199
292
  type="button"
200
293
  onClick={expand}
201
294
  title="Open workspace"
202
- className="absolute inset-y-0 right-0 flex w-6 shrink-0 items-center justify-center border-l border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
295
+ className="absolute inset-y-0 right-0 flex w-6 shrink-0 items-center justify-center border-l border-og-border bg-og-surface-1 text-og-fg-subtle hover:text-og-fg"
203
296
  >
204
297
  <ChevronsLeftRightIcon className="size-3.5" />
205
298
  </button>
@@ -207,7 +300,7 @@ export function WorkspaceDock({
207
300
 
208
301
  {/* Maximize overlay: full-workspace surface above everything. */}
209
302
  {maximized && (
210
- <div className="fixed inset-0 z-40 flex flex-col bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))]">
303
+ <div className="fixed inset-0 z-40 flex flex-col bg-og-bg">
211
304
  {dockChrome}
212
305
  </div>
213
306
  )}
@@ -215,26 +308,54 @@ export function WorkspaceDock({
215
308
  );
216
309
  }
217
310
 
311
+ /** A dock-chrome control button — compact on fine pointers, ≥40px on coarse. */
312
+ function ChromeButton({
313
+ onClick,
314
+ title,
315
+ label,
316
+ children,
317
+ }: {
318
+ onClick: () => void;
319
+ title: string;
320
+ label: string;
321
+ children: ReactNode;
322
+ }) {
323
+ return (
324
+ <button
325
+ type="button"
326
+ onClick={onClick}
327
+ title={title}
328
+ aria-label={label}
329
+ className="inline-flex items-center justify-center rounded-og-sm p-1 transition-colors hover:bg-og-surface-2 hover:text-og-fg pointer-coarse:size-10"
330
+ >
331
+ {children}
332
+ </button>
333
+ );
334
+ }
335
+
218
336
  function DockChrome({
219
337
  tabs,
220
338
  current,
221
339
  onTab,
222
- maximized,
223
- onToggleMaximize,
224
- onCollapse,
340
+ controls,
225
341
  }: {
226
342
  tabs: WorkspaceTab[];
227
343
  current: string;
228
344
  onTab: (id: string) => void;
229
- maximized: boolean;
230
- onToggleMaximize: () => void;
231
- onCollapse: () => void;
345
+ /** Right-aligned chrome controls (maximize / collapse, or the overlay close). */
346
+ controls: ReactNode;
232
347
  }) {
233
348
  const active = tabs.find((t) => t.id === current) ?? tabs[0];
234
349
  return (
235
350
  <>
236
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-1.5 py-1">
237
- <div className="flex min-w-0 items-center gap-0.5" role="tablist">
351
+ <div className="flex shrink-0 items-center gap-2 border-b border-og-border px-1.5 py-1">
352
+ {/* The tab list scrolls horizontally when it can't fit — it must never
353
+ grow into or overlap the chrome controls (they stay shrink-0). The
354
+ scrollbar is hidden to keep the strip calm. */}
355
+ <div
356
+ className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
357
+ role="tablist"
358
+ >
238
359
  {tabs.map((tab) => (
239
360
  <button
240
361
  key={tab.id}
@@ -243,35 +364,18 @@ function DockChrome({
243
364
  aria-selected={tab.id === current}
244
365
  onClick={() => onTab(tab.id)}
245
366
  className={cn(
246
- "flex items-center gap-1 rounded-[var(--og-radius-sm,4px)] px-2 py-1 text-[11px] font-medium transition-colors",
367
+ "flex shrink-0 items-center gap-1 rounded-og-sm px-2 py-1 text-og-xs font-medium transition-colors pointer-coarse:min-h-10",
247
368
  tab.id === current
248
- ? "bg-[color:var(--og-color-accent-soft,var(--color-surface-2,#222))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
249
- : "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
369
+ ? "bg-og-accent-soft text-og-fg"
370
+ : "text-og-fg-subtle hover:text-og-fg",
250
371
  )}
251
372
  >
252
- <span className="truncate">{tab.label}</span>
373
+ <span>{tab.label}</span>
253
374
  {tab.badge}
254
375
  </button>
255
376
  ))}
256
377
  </div>
257
- <div className="flex shrink-0 items-center gap-0.5 text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
258
- <button
259
- type="button"
260
- onClick={onToggleMaximize}
261
- title={maximized ? "Restore (Esc)" : "Maximize"}
262
- className="rounded-[var(--og-radius-sm,4px)] p-1 hover:bg-[color:var(--og-color-surface-2,var(--color-surface-2,#222))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
263
- >
264
- {maximized ? <Minimize2Icon className="size-3.5" /> : <Maximize2Icon className="size-3.5" />}
265
- </button>
266
- <button
267
- type="button"
268
- onClick={onCollapse}
269
- title={maximized ? "Restore" : "Collapse"}
270
- className="rounded-[var(--og-radius-sm,4px)] p-1 hover:bg-[color:var(--og-color-surface-2,var(--color-surface-2,#222))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
271
- >
272
- <PanelRightCloseIcon className="size-3.5" />
273
- </button>
274
- </div>
378
+ <div className="flex shrink-0 items-center gap-0.5 text-og-fg-subtle">{controls}</div>
275
379
  </div>
276
380
  <div className="min-h-0 min-w-0 flex-1 overflow-hidden" role="tabpanel">
277
381
  {active?.content}
@@ -1,5 +1,5 @@
1
1
  import type { FileAsset, FileResourceRef } from "@opengeni/sdk";
2
- import { useCallback, useState } from "react";
2
+ import { useCallback, useRef, useState } from "react";
3
3
  import { useOpenGeni, type ClientOverride } from "../provider";
4
4
 
5
5
  export type UseFileAttachmentsOptions = ClientOverride & {
@@ -39,6 +39,11 @@ export type UseFileAttachmentsResult = {
39
39
  addFiles: (files: Iterable<File>) => void;
40
40
  /** Clipboard path — applies `pasteFilter` (default `image/*`) then uploads. */
41
41
  addFromPaste: (event: { clipboardData: DataTransfer | null }) => void;
42
+ /**
43
+ * Re-run the upload for a `failed` attachment, in place (same id, same
44
+ * source file). No-op for an id that isn't a known failed upload.
45
+ */
46
+ retry: (id: string) => void;
42
47
  /** Remove one attachment; revokes its object-URL. */
43
48
  remove: (id: string) => void;
44
49
  /** Remove all; revokes every object-URL. Call from `useComposer`'s `onSent`. */
@@ -60,10 +65,33 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
60
65
  const { client, workspaceId } = useOpenGeni(options);
61
66
  const pasteFilter = options.pasteFilter ?? isImage;
62
67
  const [attachments, setAttachments] = useState<FileAttachment[]>([]);
68
+ // Keep the source File per attachment id so a failed upload can be retried
69
+ // in place. Cleared on remove/clear so it never outlives its attachment.
70
+ const sources = useRef<Map<string, File>>(new Map());
71
+
72
+ // Run (or re-run) the upload for one already-tracked attachment id. Sets it
73
+ // back to `uploading`, then resolves to `ready` (with the asset) or `failed`
74
+ // (with the error message).
75
+ const startUpload = useCallback((id: string, file: File) => {
76
+ void client.uploadFile(workspaceId, {
77
+ filename: file.name || "file",
78
+ contentType: file.type || "application/octet-stream",
79
+ data: file,
80
+ }).then((asset) => {
81
+ setAttachments((current) => current.map((attachment) => attachment.id === id
82
+ ? { ...attachment, status: "ready", file: asset, name: asset.filename, contentType: asset.contentType, sizeBytes: asset.sizeBytes, error: undefined }
83
+ : attachment));
84
+ }).catch((error: unknown) => {
85
+ setAttachments((current) => current.map((attachment) => attachment.id === id
86
+ ? { ...attachment, status: "failed", error: error instanceof Error ? error.message : String(error) }
87
+ : attachment));
88
+ });
89
+ }, [client, workspaceId]);
63
90
 
64
91
  const addFiles = useCallback((files: Iterable<File>) => {
65
92
  for (const file of files) {
66
93
  const id = crypto.randomUUID();
94
+ sources.current.set(id, file);
67
95
  const previewUrl = isImage(file) ? URL.createObjectURL(file) : undefined;
68
96
  setAttachments((current) => [...current, {
69
97
  id,
@@ -73,21 +101,20 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
73
101
  status: "uploading",
74
102
  ...(previewUrl ? { previewUrl } : {}),
75
103
  }]);
76
- void client.uploadFile(workspaceId, {
77
- filename: file.name || "file",
78
- contentType: file.type || "application/octet-stream",
79
- data: file,
80
- }).then((asset) => {
81
- setAttachments((current) => current.map((attachment) => attachment.id === id
82
- ? { ...attachment, status: "ready", file: asset, name: asset.filename, contentType: asset.contentType, sizeBytes: asset.sizeBytes }
83
- : attachment));
84
- }).catch((error: unknown) => {
85
- setAttachments((current) => current.map((attachment) => attachment.id === id
86
- ? { ...attachment, status: "failed", error: error instanceof Error ? error.message : String(error) }
87
- : attachment));
88
- });
104
+ startUpload(id, file);
89
105
  }
90
- }, [client, workspaceId]);
106
+ }, [startUpload]);
107
+
108
+ const retry = useCallback((id: string) => {
109
+ const file = sources.current.get(id);
110
+ if (!file) {
111
+ return;
112
+ }
113
+ setAttachments((current) => current.map((attachment) => attachment.id === id
114
+ ? { ...attachment, status: "uploading", error: undefined }
115
+ : attachment));
116
+ startUpload(id, file);
117
+ }, [startUpload]);
91
118
 
92
119
  const addFromPaste = useCallback((event: { clipboardData: DataTransfer | null }) => {
93
120
  const clipboardFiles = event.clipboardData?.files;
@@ -101,6 +128,7 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
101
128
  }, [addFiles, pasteFilter]);
102
129
 
103
130
  const remove = useCallback((id: string) => {
131
+ sources.current.delete(id);
104
132
  setAttachments((current) => {
105
133
  const removed = current.find((attachment) => attachment.id === id);
106
134
  if (removed?.previewUrl) {
@@ -111,6 +139,7 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
111
139
  }, []);
112
140
 
113
141
  const clear = useCallback(() => {
142
+ sources.current.clear();
114
143
  setAttachments((current) => {
115
144
  for (const attachment of current) {
116
145
  if (attachment.previewUrl) {
@@ -129,6 +158,7 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
129
158
  uploading: attachments.some((attachment) => attachment.status === "uploading"),
130
159
  addFiles,
131
160
  addFromPaste,
161
+ retry,
132
162
  remove,
133
163
  clear,
134
164
  };