@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.
- package/README.md +17 -6
- package/dist/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
- package/dist/chunk-5C7RGAWA.js.map +1 -0
- package/dist/index.d.ts +45 -9
- package/dist/index.js +1385 -732
- package/dist/index.js.map +1 -1
- package/dist/{machines-BD6h9P_s.d.ts → machines-BeZ3bD3t.d.ts} +2 -2
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +2 -2
- package/src/client.ts +1 -0
- package/src/components/chat-composer.tsx +127 -60
- package/src/components/code-editor.tsx +15 -15
- package/src/components/desktop-viewer.tsx +40 -36
- package/src/components/diff-view.tsx +22 -22
- package/src/components/enrollment-consent.tsx +13 -13
- package/src/components/file-browser.tsx +74 -67
- package/src/components/fleet-tile.tsx +3 -3
- package/src/components/machine-card.tsx +6 -6
- package/src/components/machine-status-pill.tsx +3 -3
- package/src/components/machines-dashboard.tsx +28 -14
- package/src/components/markdown.tsx +6 -6
- package/src/components/message-timeline.tsx +207 -12
- package/src/components/pierre-diff.tsx +9 -10
- package/src/components/pierre-file.tsx +8 -8
- package/src/components/sandbox-files.tsx +37 -40
- package/src/components/sandbox-terminal.tsx +9 -11
- package/src/components/session-status.tsx +2 -2
- package/src/components/workspace-dock.tsx +153 -49
- package/src/hooks/use-file-attachments.ts +45 -15
- package/src/hooks/use-session-events.ts +283 -8
- package/src/index.ts +2 -2
- package/src/lib/format.ts +32 -0
- package/src/lib/xterm-theme.ts +8 -11
- package/src/timeline/activity-rail.tsx +5 -2
- package/src/timeline/entrance.tsx +30 -0
- package/src/timeline/parsers.ts +104 -0
- package/src/timeline/projection.ts +22 -5
- package/src/timeline/screenshot-lightbox.tsx +1 -1
- package/src/timeline/shared.tsx +4 -1
- package/src/timeline/tool-diff.tsx +1 -1
- package/src/timeline/tool-renderers.tsx +46 -7
- package/src/timeline/turn-summary.tsx +24 -4
- package/styles/tokens.css +8 -0
- 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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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: "
|
|
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-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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-
|
|
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-
|
|
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:
|
|
197
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
223
|
-
onToggleMaximize,
|
|
224
|
-
onCollapse,
|
|
340
|
+
controls,
|
|
225
341
|
}: {
|
|
226
342
|
tabs: WorkspaceTab[];
|
|
227
343
|
current: string;
|
|
228
344
|
onTab: (id: string) => void;
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
237
|
-
|
|
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-
|
|
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-
|
|
249
|
-
: "text-
|
|
369
|
+
? "bg-og-accent-soft text-og-fg"
|
|
370
|
+
: "text-og-fg-subtle hover:text-og-fg",
|
|
250
371
|
)}
|
|
251
372
|
>
|
|
252
|
-
<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-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
};
|