@opengeni/react 0.15.0 → 0.20.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 (101) hide show
  1. package/README.md +110 -10
  2. package/dist/{chunk-TOJR776I.js → chunk-6BXBGJ3B.js} +45 -369
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +1288 -1785
  21. package/dist/index.js +7015 -8806
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +3 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +20 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +40 -0
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +94 -993
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +1 -1
  51. package/src/components/machines-dashboard.tsx +3 -3
  52. package/src/components/message-timeline.tsx +15 -7
  53. package/src/components/model-picker.tsx +12 -3
  54. package/src/components/pierre-diff.tsx +32 -6
  55. package/src/components/queue-surface-implementation.tsx +1041 -0
  56. package/src/components/queue-surface-state.tsx +94 -0
  57. package/src/components/queue-surface.tsx +41 -559
  58. package/src/components/sandbox-files.tsx +138 -14
  59. package/src/components/sandbox-terminal.tsx +8 -2
  60. package/src/components/sandbox-workspace.tsx +339 -119
  61. package/src/components/workbench-changes.tsx +84 -33
  62. package/src/components/workspace-dock.tsx +188 -18
  63. package/src/composer.ts +60 -0
  64. package/src/hooks/internal.ts +115 -34
  65. package/src/hooks/use-composer.ts +335 -104
  66. package/src/hooks/use-human-input.ts +131 -0
  67. package/src/hooks/use-machine-chip.ts +2 -2
  68. package/src/hooks/use-machines.ts +27 -11
  69. package/src/hooks/use-sandbox-files.ts +310 -62
  70. package/src/hooks/use-sandbox-git.ts +154 -40
  71. package/src/hooks/use-sandbox-terminal.ts +28 -6
  72. package/src/hooks/use-session-capabilities.ts +38 -9
  73. package/src/hooks/use-session-control.ts +37 -8
  74. package/src/hooks/use-session-events.ts +522 -48
  75. package/src/hooks/use-slash-commands.ts +38 -38
  76. package/src/hooks/use-transcription.ts +757 -0
  77. package/src/hooks/use-turn-queue.ts +134 -66
  78. package/src/hooks/use-windowed-sections.ts +2 -2
  79. package/src/hooks/use-workspace-capture.ts +146 -40
  80. package/src/hooks/use-workspace-edit.ts +50 -14
  81. package/src/hooks/use-workspace-sessions.ts +3 -0
  82. package/src/human-input.ts +72 -0
  83. package/src/index.ts +76 -6
  84. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  85. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  86. package/src/lib/use-portal-token-style.ts +51 -0
  87. package/src/lib/use-unicode-fonts.ts +57 -0
  88. package/src/provider.tsx +4 -69
  89. package/src/session-context.ts +135 -0
  90. package/src/session.ts +79 -0
  91. package/src/timeline/parsers.ts +35 -0
  92. package/src/timeline/projection.ts +12 -4
  93. package/src/timeline/tool-renderers.tsx +41 -0
  94. package/src/timeline/turn-summary.tsx +2 -2
  95. package/src/timeline/types.ts +16 -8
  96. package/styles/index.css +56 -0
  97. package/styles/index.d.ts +2 -0
  98. package/styles/tokens.css +11 -6
  99. package/styles/tokens.d.ts +2 -0
  100. package/dist/chunk-TOJR776I.js.map +0 -1
  101. package/dist/machines-BpdwuQcD.d.ts +0 -449
@@ -0,0 +1,1536 @@
1
+ import type { ClientModel, EffectiveSessionControl } from "@opengeni/sdk";
2
+ import {
3
+ ArrowUpIcon,
4
+ ChevronDownIcon,
5
+ FileIcon,
6
+ ImageIcon,
7
+ LoaderCircleIcon,
8
+ PaperclipIcon,
9
+ PauseIcon,
10
+ PlayIcon,
11
+ RotateCwIcon,
12
+ XIcon,
13
+ } from "lucide-react";
14
+ import { AnimatePresence, motion } from "motion/react";
15
+ import {
16
+ createContext,
17
+ forwardRef,
18
+ useCallback,
19
+ useContext,
20
+ useEffect,
21
+ useId,
22
+ useMemo,
23
+ useRef,
24
+ useState,
25
+ type ButtonHTMLAttributes,
26
+ type ChangeEvent,
27
+ type ClipboardEvent,
28
+ type ComponentPropsWithoutRef,
29
+ type DragEvent,
30
+ type KeyboardEvent,
31
+ type ReactNode,
32
+ type Ref,
33
+ type RefObject,
34
+ } from "react";
35
+ import { argHint, defaultCommands } from "../commands/registry";
36
+ import type { Notice, SlashCommand } from "../commands/types";
37
+ import type { ComposerState } from "../hooks/use-composer";
38
+ import { shouldSteerOnKey, shouldSubmitOnKey } from "../hooks/use-composer";
39
+ import type { UseFileAttachmentsResult } from "../hooks/use-file-attachments";
40
+ import {
41
+ useSlashCommands,
42
+ type ConfirmState,
43
+ type SlashCommandContext,
44
+ } from "../hooks/use-slash-commands";
45
+ import { cn } from "../lib/cn";
46
+ import { formatBytes, formatRelativeTime } from "../lib/format";
47
+ import { CommandPalette as CommandPaletteView } from "./command-palette";
48
+ import { ModelPicker as ModelPickerView } from "./model-picker";
49
+
50
+ export const OPEN_WORKSTREAM_CONTROL_EVENT = "opengeni:open-workstream-control";
51
+
52
+ export type ComposerDelivery = Pick<
53
+ ComposerState,
54
+ "value" | "setValue" | "send" | "steer" | "sending" | "canSend" | "error" | "clearError"
55
+ >;
56
+
57
+ export type ComposerDraftState = Pick<
58
+ ComposerState,
59
+ | "draftConflict"
60
+ | "draftSaving"
61
+ | "resolveDraftConflict"
62
+ | "restoredResources"
63
+ | "removeRestoredResource"
64
+ >;
65
+
66
+ export type ComposerControlState = Pick<
67
+ ComposerState,
68
+ "pause" | "pausing" | "resume" | "resumeScope" | "resuming"
69
+ >;
70
+
71
+ export type ComposerControlLinks = {
72
+ workspaceHref?: string | undefined;
73
+ sessionHref?: ((sessionId: string) => string) | undefined;
74
+ };
75
+
76
+ export type ChatComposerMessages = {
77
+ messagePlaceholder: string;
78
+ pausedPlaceholder: string;
79
+ inputLabel: string;
80
+ keyboardHint: string;
81
+ slashCommandBlocked: string;
82
+ controlChangedError: string;
83
+ sendFailedError: string;
84
+ dropFiles: string;
85
+ attachFiles: string;
86
+ pauseAriaLabel: string;
87
+ pauseTitle: string;
88
+ sendMessageAriaLabel: string;
89
+ sendAndResumeAriaLabel: string;
90
+ sendTitle: string;
91
+ sendAndResumeTitle: string;
92
+ workspacePaused: string;
93
+ pausedHere: string;
94
+ parentBlocker: string;
95
+ narrowerPause: string;
96
+ pausedBy: (displayName: string) => string;
97
+ queuedAhead: (count: number) => string;
98
+ resumingAndSending: string;
99
+ nextMessageResumes: string;
100
+ resumeThisWorkstream: string;
101
+ resumeShort: string;
102
+ hidePauseDetails: string;
103
+ showPauseDetails: string;
104
+ pauseReasonsLabel: string;
105
+ pausedByLabel: string;
106
+ alsoPausedByLabel: string;
107
+ resumeWorkspace: string;
108
+ resumeFromSession: string;
109
+ sessionCanRun: string;
110
+ stillPausedBy: (displayName: string) => string;
111
+ restoredResourcesLabel: string;
112
+ restoredFile: (fileId: string) => string;
113
+ removeRestoredResource: (index: number) => string;
114
+ uploading: string;
115
+ uploadFailed: string;
116
+ retryAttachment: (name: string) => string;
117
+ retryUpload: string;
118
+ removeAttachment: (name: string) => string;
119
+ confirmCommand: (name: string) => string;
120
+ confirmDescription: (command: SlashCommand) => string;
121
+ cancel: string;
122
+ runCommand: (name: string) => string;
123
+ commands: string;
124
+ slashCommandsLabel: string;
125
+ modelLabel: string;
126
+ close: string;
127
+ danger: string;
128
+ draftConflict: string;
129
+ useOtherDraft: string;
130
+ keepMine: string;
131
+ savingDraft: string;
132
+ formatBytes: (bytes: number) => string;
133
+ formatRelativeTime: (changedAt: string) => string;
134
+ };
135
+
136
+ export const defaultChatComposerMessages: ChatComposerMessages = {
137
+ messagePlaceholder: "Message the agent…",
138
+ pausedPlaceholder: "Sending will resume this workstream…",
139
+ inputLabel: "Message the agent",
140
+ keyboardHint: "Enter to queue · Cmd/Ctrl+Enter to steer · Shift+Enter for a new line",
141
+ slashCommandBlocked:
142
+ "That's a slash command — press Enter in the command list to run it, or edit the line to send a message.",
143
+ controlChangedError:
144
+ "This workstream was paused while you were sending. Nothing was sent, and your draft is still here.",
145
+ sendFailedError: "Sending failed — your draft is still here. Try again.",
146
+ dropFiles: "Drop files to attach",
147
+ attachFiles: "Attach files",
148
+ pauseAriaLabel: "Pause this workstream",
149
+ pauseTitle: "Pause this workstream; queued prompts and approvals are preserved",
150
+ sendMessageAriaLabel: "Send message",
151
+ sendAndResumeAriaLabel: "Send and resume",
152
+ sendTitle: "Queue message (Enter); steer with Cmd/Ctrl+Enter",
153
+ sendAndResumeTitle: "Send & resume (Enter); Steer & resume with Cmd/Ctrl+Enter",
154
+ workspacePaused: "Workspace paused",
155
+ pausedHere: "Paused here",
156
+ parentBlocker: "parent",
157
+ narrowerPause: "a narrower pause",
158
+ pausedBy: (displayName) => `Paused by ${displayName}`,
159
+ queuedAhead: (count) =>
160
+ `Send & resume queues behind ${count} waiting prompt${count === 1 ? "" : "s"}.`,
161
+ resumingAndSending: "Resuming and sending…",
162
+ nextMessageResumes: "Your next message resumes this workstream automatically.",
163
+ resumeThisWorkstream: "Resume this workstream",
164
+ resumeShort: "Resume",
165
+ hidePauseDetails: "Hide pause details",
166
+ showPauseDetails: "Show pause details",
167
+ pauseReasonsLabel: "Reasons this workstream is paused",
168
+ pausedByLabel: "Paused by ",
169
+ alsoPausedByLabel: "Also paused by ",
170
+ resumeWorkspace: "Resume workspace",
171
+ resumeFromSession: "Resume from this session",
172
+ sessionCanRun: "This session will be able to run",
173
+ stillPausedBy: (displayName) => `Still paused by ${displayName}`,
174
+ restoredResourcesLabel: "Restored prompt resources",
175
+ restoredFile: (fileId) => `File ${fileId.slice(0, 8)}`,
176
+ removeRestoredResource: (index) => `Remove restored resource ${index + 1}`,
177
+ uploading: "Uploading",
178
+ uploadFailed: "Upload failed",
179
+ retryAttachment: (name) => `Retry ${name}`,
180
+ retryUpload: "Retry upload",
181
+ removeAttachment: (name) => `Remove ${name}`,
182
+ confirmCommand: (name) => `Confirm /${name}`,
183
+ confirmDescription: (command) => `Run /${command.name}? ${command.description}`,
184
+ cancel: "Cancel",
185
+ runCommand: (name) => `Run /${name}`,
186
+ commands: "Commands",
187
+ slashCommandsLabel: "Slash commands",
188
+ modelLabel: "Model",
189
+ close: "Close",
190
+ danger: "danger",
191
+ draftConflict: "This draft changed in another tab. Your local draft is still here.",
192
+ useOtherDraft: "Use other draft",
193
+ keepMine: "Keep mine",
194
+ savingDraft: "Saving draft…",
195
+ formatBytes,
196
+ formatRelativeTime,
197
+ };
198
+
199
+ export type ComposerSubmitMode = "queue" | "steer";
200
+ export type ComposerSubmitBlocker =
201
+ | "disabled"
202
+ | "uploading"
203
+ | "sending"
204
+ | "command"
205
+ | "empty"
206
+ | null;
207
+
208
+ export type UseChatComposerControllerOptions = {
209
+ delivery: ComposerDelivery;
210
+ draft?: ComposerDraftState | undefined;
211
+ control?: ComposerControlState | undefined;
212
+ effectiveControl?: EffectiveSessionControl | null | undefined;
213
+ queuedAheadCount?: number | undefined;
214
+ canControlWorkspace?: boolean | undefined;
215
+ controlLinks?: ComposerControlLinks | undefined;
216
+ disabled?: boolean | undefined;
217
+ attachments?: UseFileAttachmentsResult | undefined;
218
+ commands?: readonly SlashCommand[] | undefined;
219
+ commandContext?: SlashCommandContext | undefined;
220
+ onClearView?: (() => void) | undefined;
221
+ onPaste?: ((event: ClipboardEvent<HTMLTextAreaElement>) => void) | undefined;
222
+ messages?: Partial<ChatComposerMessages> | undefined;
223
+ };
224
+
225
+ /**
226
+ * Headless interaction layer for a chat composer. It is the single owner of
227
+ * delivery guards, keyboard routing, command interception, drag/drop, focus,
228
+ * confirmation, and feedback state used by both the preset and primitives.
229
+ */
230
+ export function useChatComposerController({
231
+ delivery,
232
+ draft,
233
+ control,
234
+ effectiveControl,
235
+ queuedAheadCount = 0,
236
+ canControlWorkspace = false,
237
+ controlLinks,
238
+ disabled = false,
239
+ attachments,
240
+ commands = defaultCommands,
241
+ commandContext,
242
+ onClearView,
243
+ onPaste,
244
+ messages: messageOverrides,
245
+ }: UseChatComposerControllerOptions) {
246
+ const messages = useMemo(
247
+ () => ({ ...defaultChatComposerMessages, ...messageOverrides }),
248
+ [messageOverrides],
249
+ );
250
+ const id = useId();
251
+ const rootRef = useRef<HTMLDivElement | null>(null);
252
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
253
+ const pauseButtonRef = useRef<HTMLButtonElement | null>(null);
254
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
255
+ const listboxId = useId();
256
+ const paused = effectiveControl?.state === "paused";
257
+ const [controlDetailsOpen, setControlDetailsOpen] = useState(false);
258
+ const [paletteMounted, setPaletteMounted] = useState(false);
259
+ const [submitting, setSubmitting] = useState(false);
260
+ const submittingRef = useRef(false);
261
+ const controlOperationRef = useRef(false);
262
+ const mountedRef = useRef(true);
263
+
264
+ useEffect(() => {
265
+ mountedRef.current = true;
266
+ return () => {
267
+ mountedRef.current = false;
268
+ };
269
+ }, []);
270
+
271
+ useEffect(() => {
272
+ const openControl = (event: Event) => {
273
+ const requestedId =
274
+ event instanceof CustomEvent &&
275
+ isRecord(event.detail) &&
276
+ typeof event.detail.composerId === "string"
277
+ ? event.detail.composerId
278
+ : null;
279
+ if (requestedId && requestedId !== id) return;
280
+ if (!requestedId) {
281
+ const roots = document.querySelectorAll("[data-og-composer-id]");
282
+ if (roots.length > 1 && !rootRef.current?.contains(document.activeElement)) return;
283
+ }
284
+ textareaRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
285
+ if (paused) {
286
+ setControlDetailsOpen(true);
287
+ return;
288
+ }
289
+ window.requestAnimationFrame(() => pauseButtonRef.current?.focus());
290
+ };
291
+ document.addEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl);
292
+ return () => document.removeEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl);
293
+ }, [id, paused]);
294
+
295
+ const blockedByUpload = attachments?.uploading === true;
296
+ const hasReadyAttachment = (attachments?.readyResources.length ?? 0) > 0;
297
+
298
+ const [dragging, setDragging] = useState(false);
299
+ const dragCarriesFiles = (event: { dataTransfer: DataTransfer | null }): boolean =>
300
+ event.dataTransfer !== null && [...event.dataTransfer.types].includes("Files");
301
+ const handleDragOver = useCallback(
302
+ (event: DragEvent<HTMLDivElement>) => {
303
+ if (!attachments || !dragCarriesFiles(event)) return;
304
+ event.preventDefault();
305
+ setDragging(true);
306
+ },
307
+ [attachments],
308
+ );
309
+ const handleDragLeave = useCallback(
310
+ (event: DragEvent<HTMLDivElement>) => {
311
+ if (!attachments) return;
312
+ if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
313
+ setDragging(false);
314
+ },
315
+ [attachments],
316
+ );
317
+ const handleDrop = useCallback(
318
+ (event: DragEvent<HTMLDivElement>) => {
319
+ if (!attachments || !dragCarriesFiles(event)) return;
320
+ event.preventDefault();
321
+ setDragging(false);
322
+ if (event.dataTransfer.files.length > 0) attachments.addFiles(event.dataTransfer.files);
323
+ },
324
+ [attachments],
325
+ );
326
+
327
+ const [notice, setNotice] = useState<Notice | null>(null);
328
+ const [helpOpen, setHelpOpen] = useState(false);
329
+ const [confirmState, setConfirmState] = useState<ConfirmState>(null);
330
+ const pendingConfirm = useRef<((confirmed: boolean) => void) | null>(null);
331
+
332
+ const settleConfirmation = useCallback((confirmed: boolean) => {
333
+ const resolve = pendingConfirm.current;
334
+ pendingConfirm.current = null;
335
+ setConfirmState(null);
336
+ resolve?.(confirmed);
337
+ }, []);
338
+
339
+ useEffect(
340
+ () => () => {
341
+ pendingConfirm.current?.(false);
342
+ pendingConfirm.current = null;
343
+ },
344
+ [],
345
+ );
346
+
347
+ const resizeInput = useCallback(() => {
348
+ const textarea = textareaRef.current;
349
+ if (!textarea) return;
350
+ textarea.style.height = "0px";
351
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`;
352
+ }, []);
353
+ useEffect(() => resizeInput(), [delivery.value, resizeInput]);
354
+
355
+ const handlers = useMemo(
356
+ () => ({
357
+ notice: (next: Notice) => {
358
+ setNotice(next);
359
+ delivery.clearError();
360
+ },
361
+ openHelp: () => setHelpOpen(true),
362
+ clearView: () => {
363
+ if (!onClearView) return false;
364
+ onClearView();
365
+ return true;
366
+ },
367
+ confirm: (command: SlashCommand) =>
368
+ new Promise<boolean>((resolve) => {
369
+ pendingConfirm.current?.(false);
370
+ pendingConfirm.current = resolve;
371
+ setConfirmState({ command, resolve: settleConfirmation });
372
+ }),
373
+ }),
374
+ [delivery, onClearView, settleConfirmation],
375
+ );
376
+
377
+ const palette = useSlashCommands({
378
+ commands,
379
+ context: commandContext,
380
+ handlers,
381
+ value: delivery.value,
382
+ setValue: delivery.setValue,
383
+ });
384
+ const paletteEnabled = commandContext !== undefined;
385
+ const commandDraftBlocked = paletteEnabled && palette.isCommandDraft;
386
+
387
+ const submitBlocker: ComposerSubmitBlocker = disabled
388
+ ? "disabled"
389
+ : blockedByUpload
390
+ ? "uploading"
391
+ : delivery.sending || submitting
392
+ ? "sending"
393
+ : commandDraftBlocked
394
+ ? "command"
395
+ : delivery.canSend || hasReadyAttachment
396
+ ? null
397
+ : "empty";
398
+ const canSubmit = submitBlocker === null;
399
+
400
+ const submit = useCallback(
401
+ async (mode: ComposerSubmitMode): Promise<boolean> => {
402
+ if (commandDraftBlocked) {
403
+ setNotice({ tone: "error", message: messages.slashCommandBlocked });
404
+ delivery.clearError();
405
+ return false;
406
+ }
407
+ if (disabled || blockedByUpload || delivery.sending || submittingRef.current) return false;
408
+ if (!delivery.canSend && !hasReadyAttachment) return false;
409
+ submittingRef.current = true;
410
+ setSubmitting(true);
411
+ try {
412
+ return mode === "steer" ? await delivery.steer() : await delivery.send();
413
+ } finally {
414
+ submittingRef.current = false;
415
+ if (mountedRef.current) setSubmitting(false);
416
+ }
417
+ },
418
+ [
419
+ blockedByUpload,
420
+ commandDraftBlocked,
421
+ delivery,
422
+ disabled,
423
+ hasReadyAttachment,
424
+ messages.slashCommandBlocked,
425
+ ],
426
+ );
427
+
428
+ const handleKeyDown = useCallback(
429
+ (event: KeyboardEvent<HTMLTextAreaElement>) => {
430
+ if (paletteEnabled && paletteMounted && palette.onKeyDown(event)) return;
431
+ if (!shouldSubmitOnKey(event)) return;
432
+ event.preventDefault();
433
+ void submit(shouldSteerOnKey(event) ? "steer" : "queue");
434
+ },
435
+ [palette, paletteEnabled, paletteMounted, submit],
436
+ );
437
+ const handlePaste = useCallback(
438
+ (event: ClipboardEvent<HTMLTextAreaElement>) => {
439
+ onPaste?.(event);
440
+ attachments?.addFromPaste(event);
441
+ },
442
+ [attachments, onPaste],
443
+ );
444
+ const handleFileChange = useCallback(
445
+ (event: ChangeEvent<HTMLInputElement>) => {
446
+ if (event.target.files) attachments?.addFiles(event.target.files);
447
+ event.target.value = "";
448
+ },
449
+ [attachments],
450
+ );
451
+
452
+ const helpCommands = useMemo(
453
+ () =>
454
+ commands.filter((command) => {
455
+ if (command.permission && commandContext) {
456
+ const permissions = commandContext.permissions;
457
+ return (
458
+ permissions.includes(command.permission) || permissions.includes("workspace:admin")
459
+ );
460
+ }
461
+ return true;
462
+ }),
463
+ [commandContext, commands],
464
+ );
465
+ const activeNotice =
466
+ notice ??
467
+ (delivery.error
468
+ ? {
469
+ tone: "error" as const,
470
+ message: /control changed|paused while/i.test(delivery.error.message)
471
+ ? messages.controlChangedError
472
+ : delivery.error.message || messages.sendFailedError,
473
+ }
474
+ : null);
475
+ useEffect(() => {
476
+ if (notice?.tone !== "ok") return;
477
+ const timer = window.setTimeout(
478
+ () => setNotice((current) => (current === notice ? null : current)),
479
+ 2400,
480
+ );
481
+ return () => window.clearTimeout(timer);
482
+ }, [notice]);
483
+
484
+ const runControlOperation = useCallback(async (operation: () => Promise<void>) => {
485
+ if (controlOperationRef.current) return false;
486
+ controlOperationRef.current = true;
487
+ try {
488
+ await operation();
489
+ return true;
490
+ } finally {
491
+ controlOperationRef.current = false;
492
+ }
493
+ }, []);
494
+
495
+ const pause = useCallback(
496
+ async (reason?: string): Promise<boolean> => {
497
+ if (!control || control.pausing || control.resuming) return false;
498
+ return await runControlOperation(() => control.pause(reason));
499
+ },
500
+ [control, runControlOperation],
501
+ );
502
+ const resume = useCallback(
503
+ async (reason?: string): Promise<boolean> => {
504
+ if (!control || control.pausing || control.resuming) return false;
505
+ return await runControlOperation(() => control.resume(reason));
506
+ },
507
+ [control, runControlOperation],
508
+ );
509
+ const resumeScope = useCallback(
510
+ async (option: EffectiveSessionControl["resumeOptions"][number]): Promise<boolean> => {
511
+ if (!control || control.pausing || control.resuming) return false;
512
+ return await runControlOperation(() => control.resumeScope(option));
513
+ },
514
+ [control, runControlOperation],
515
+ );
516
+
517
+ return {
518
+ id,
519
+ rootRef,
520
+ textareaRef,
521
+ pauseButtonRef,
522
+ fileInputRef,
523
+ listboxId,
524
+ effectiveControl,
525
+ queuedAheadCount,
526
+ canControlWorkspace,
527
+ controlLinks,
528
+ disabled,
529
+ attachments,
530
+ messages,
531
+ sending: delivery.sending || submitting,
532
+ error: delivery.error,
533
+ clearError: delivery.clearError,
534
+ hasDraftState: draft !== undefined,
535
+ draftConflict: draft?.draftConflict ?? null,
536
+ draftSaving: draft?.draftSaving ?? false,
537
+ restoredResources: draft?.restoredResources ?? [],
538
+ removeRestoredResource: draft?.removeRestoredResource,
539
+ resolveDraftConflict: draft?.resolveDraftConflict,
540
+ hasControl: control !== undefined,
541
+ pausing: control?.pausing ?? false,
542
+ resuming: control?.resuming ?? false,
543
+ pause,
544
+ resume,
545
+ resumeScope,
546
+ paused,
547
+ controlDetailsOpen,
548
+ setControlDetailsOpen,
549
+ paletteMounted,
550
+ setPaletteMounted,
551
+ dragging,
552
+ handleDragOver,
553
+ handleDragLeave,
554
+ handleDrop,
555
+ palette,
556
+ paletteEnabled,
557
+ commandDraftBlocked,
558
+ confirmState,
559
+ settleConfirmation,
560
+ helpOpen,
561
+ setHelpOpen,
562
+ helpCommands,
563
+ activeNotice,
564
+ canSubmit,
565
+ submitBlocker,
566
+ submit,
567
+ handleKeyDown,
568
+ handlePaste,
569
+ handleFileChange,
570
+ focusInput: () => textareaRef.current?.focus(),
571
+ setValue: delivery.setValue,
572
+ value: delivery.value,
573
+ };
574
+ }
575
+
576
+ export type ChatComposerController = ReturnType<typeof useChatComposerController>;
577
+
578
+ const ComposerContext = createContext<ChatComposerController | null>(null);
579
+
580
+ function useComposerController(): ChatComposerController {
581
+ const controller = useContext(ComposerContext);
582
+ if (!controller) throw new Error("useChatComposer must be used inside <Composer.Root>");
583
+ return controller;
584
+ }
585
+
586
+ export type ChatComposerContextValue = Pick<
587
+ ChatComposerController,
588
+ | "id"
589
+ | "value"
590
+ | "setValue"
591
+ | "focusInput"
592
+ | "submit"
593
+ | "canSubmit"
594
+ | "submitBlocker"
595
+ | "disabled"
596
+ | "sending"
597
+ | "error"
598
+ | "clearError"
599
+ | "attachments"
600
+ | "messages"
601
+ | "effectiveControl"
602
+ | "paused"
603
+ | "hasControl"
604
+ | "pausing"
605
+ | "resuming"
606
+ | "pause"
607
+ | "resume"
608
+ | "resumeScope"
609
+ >;
610
+
611
+ /** Read the nearest compound composer's safe accessory-facing state and actions. */
612
+ export function useChatComposer(): ChatComposerContextValue {
613
+ return useComposerController();
614
+ }
615
+
616
+ function isRecord(value: unknown): value is Record<string, unknown> {
617
+ return typeof value === "object" && value !== null;
618
+ }
619
+
620
+ export type ComposerRootProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
621
+ controller: ChatComposerController;
622
+ children: ReactNode;
623
+ };
624
+
625
+ export const Root = forwardRef<HTMLDivElement, ComposerRootProps>(function ComposerRoot(
626
+ { controller, children, className, style, ...props },
627
+ forwardedRef,
628
+ ) {
629
+ return (
630
+ <ComposerContext.Provider value={controller}>
631
+ <div
632
+ {...props}
633
+ ref={mergeRefs(controller.rootRef, forwardedRef)}
634
+ data-og-composer-id={controller.id}
635
+ className={cn("og-root", className)}
636
+ style={{ paddingBottom: "env(safe-area-inset-bottom)", ...style }}
637
+ >
638
+ {children}
639
+ <ComposerAnnouncements />
640
+ </div>
641
+ </ComposerContext.Provider>
642
+ );
643
+ });
644
+
645
+ export type ComposerFrameProps = ComponentPropsWithoutRef<"div">;
646
+
647
+ export const Frame = forwardRef<HTMLDivElement, ComposerFrameProps>(function ComposerFrame(
648
+ { className, ...props },
649
+ ref,
650
+ ) {
651
+ return <div {...props} ref={ref} className={cn("relative", className)} />;
652
+ });
653
+
654
+ export type ComposerCommandPaletteProps = { className?: string | undefined };
655
+
656
+ export function CommandPalette({ className }: ComposerCommandPaletteProps) {
657
+ const controller = useComposerController();
658
+ const setPaletteMounted = controller.setPaletteMounted;
659
+ useEffect(() => {
660
+ setPaletteMounted(true);
661
+ return () => setPaletteMounted(false);
662
+ }, [setPaletteMounted]);
663
+ if (!controller.paletteEnabled) return null;
664
+ return (
665
+ <div className={className}>
666
+ <CommandPaletteView
667
+ open={controller.palette.open && controller.confirmState === null}
668
+ items={controller.palette.items}
669
+ highlight={controller.palette.highlight}
670
+ onHighlight={controller.palette.setHighlight}
671
+ onRun={(index) => {
672
+ controller.palette.setHighlight(index);
673
+ void controller.palette.runAt(index);
674
+ }}
675
+ argHintText={controller.palette.activeArgHint}
676
+ listboxId={controller.listboxId}
677
+ label={controller.messages.slashCommandsLabel}
678
+ dangerLabel={controller.messages.danger}
679
+ />
680
+ </div>
681
+ );
682
+ }
683
+
684
+ type OwnedSurfaceProps = "onDragOver" | "onDragLeave" | "onDrop";
685
+ export type ComposerSurfaceProps = Omit<ComponentPropsWithoutRef<"div">, OwnedSurfaceProps>;
686
+
687
+ export const Surface = forwardRef<HTMLDivElement, ComposerSurfaceProps>(function ComposerSurface(
688
+ { className, children, ...props },
689
+ ref,
690
+ ) {
691
+ const controller = useComposerController();
692
+ return (
693
+ <div
694
+ {...props}
695
+ ref={ref}
696
+ onDragOver={controller.attachments ? controller.handleDragOver : undefined}
697
+ onDragLeave={controller.attachments ? controller.handleDragLeave : undefined}
698
+ onDrop={controller.attachments ? controller.handleDrop : undefined}
699
+ className={cn(
700
+ "relative rounded-og-lg border border-og-border bg-og-surface-1 shadow-og-sm",
701
+ "transition-[border-color,box-shadow] duration-200",
702
+ "focus-within:border-og-accent/60 focus-within:shadow-og-glow",
703
+ controller.dragging && "border-dashed border-og-accent",
704
+ className,
705
+ )}
706
+ >
707
+ {controller.dragging ? (
708
+ <div
709
+ aria-hidden
710
+ className={cn(
711
+ "pointer-events-none absolute inset-0 z-10 flex items-center justify-center",
712
+ "rounded-og-lg bg-og-surface-1/85 text-sm font-medium text-og-accent backdrop-blur-[1px]",
713
+ )}
714
+ >
715
+ <span className="inline-flex items-center gap-2">
716
+ <PaperclipIcon className="size-4" />
717
+ {controller.messages.dropFiles}
718
+ </span>
719
+ </div>
720
+ ) : null}
721
+ {children}
722
+ </div>
723
+ );
724
+ });
725
+
726
+ export function PausedState() {
727
+ const controller = useComposerController();
728
+ if (!controller.paused || !controller.effectiveControl || !controller.hasControl) return null;
729
+ return (
730
+ <WorkstreamPausedStrip
731
+ control={controller.effectiveControl}
732
+ queuedAheadCount={controller.queuedAheadCount}
733
+ open={controller.controlDetailsOpen}
734
+ busy={controller.resuming}
735
+ sending={controller.sending}
736
+ canControlWorkspace={controller.canControlWorkspace}
737
+ controlLinks={controller.controlLinks}
738
+ messages={controller.messages}
739
+ onOpenChange={controller.setControlDetailsOpen}
740
+ onResume={() => void controller.resume()}
741
+ onResumeOption={(option) => void controller.resumeScope(option)}
742
+ />
743
+ );
744
+ }
745
+
746
+ export function RestoredResources() {
747
+ const controller = useComposerController();
748
+ if (controller.restoredResources.length === 0 || !controller.removeRestoredResource) return null;
749
+ return (
750
+ <RestoredResourceChips
751
+ resources={controller.restoredResources}
752
+ messages={controller.messages}
753
+ onRemove={controller.removeRestoredResource}
754
+ />
755
+ );
756
+ }
757
+
758
+ export function Attachments() {
759
+ const controller = useComposerController();
760
+ if (!controller.attachments || controller.attachments.attachments.length === 0) return null;
761
+ return (
762
+ <AttachmentChips
763
+ attachments={controller.attachments.attachments}
764
+ messages={controller.messages}
765
+ onRemove={controller.attachments.remove}
766
+ onRetry={controller.attachments.retry}
767
+ />
768
+ );
769
+ }
770
+
771
+ type OwnedInputProps =
772
+ | "value"
773
+ | "defaultValue"
774
+ | "onChange"
775
+ | "onKeyDown"
776
+ | "onPaste"
777
+ | "disabled"
778
+ | "aria-autocomplete"
779
+ | "aria-controls"
780
+ | "aria-activedescendant"
781
+ | "aria-keyshortcuts";
782
+ export type ComposerInputProps = Omit<ComponentPropsWithoutRef<"textarea">, OwnedInputProps>;
783
+
784
+ export const Input = forwardRef<HTMLTextAreaElement, ComposerInputProps>(function ComposerInput(
785
+ { rows = 1, placeholder, className, "aria-label": ariaLabel, ...props },
786
+ forwardedRef,
787
+ ) {
788
+ const controller = useComposerController();
789
+ const paletteOpen =
790
+ controller.paletteEnabled && controller.paletteMounted && controller.palette.open;
791
+ return (
792
+ <textarea
793
+ {...props}
794
+ ref={mergeRefs(controller.textareaRef, forwardedRef)}
795
+ rows={rows}
796
+ value={controller.value}
797
+ onChange={(event) => controller.setValue(event.target.value)}
798
+ onKeyDown={controller.handleKeyDown}
799
+ onPaste={controller.handlePaste}
800
+ placeholder={
801
+ controller.paused && !controller.disabled
802
+ ? controller.messages.pausedPlaceholder
803
+ : (placeholder ?? controller.messages.messagePlaceholder)
804
+ }
805
+ disabled={controller.disabled}
806
+ aria-label={ariaLabel ?? controller.messages.inputLabel}
807
+ aria-keyshortcuts="Enter Meta+Enter Control+Enter Shift+Enter"
808
+ aria-autocomplete={
809
+ controller.paletteEnabled && controller.paletteMounted ? "list" : undefined
810
+ }
811
+ aria-controls={paletteOpen ? controller.listboxId : undefined}
812
+ aria-activedescendant={
813
+ paletteOpen ? `${controller.listboxId}-option-${controller.palette.highlight}` : undefined
814
+ }
815
+ className={cn(
816
+ "block w-full resize-none bg-transparent px-4 pt-3.5 pb-1 text-base leading-6 md:text-og-md",
817
+ "text-og-fg placeholder:text-og-fg-subtle focus:outline-none focus-visible:outline-none",
818
+ "disabled:cursor-not-allowed disabled:opacity-60",
819
+ className,
820
+ )}
821
+ />
822
+ );
823
+ });
824
+
825
+ export function Confirmation() {
826
+ const controller = useComposerController();
827
+ const command = controller.confirmState?.command;
828
+ if (!command || !controller.confirmState) return null;
829
+ return (
830
+ <ConfirmBar
831
+ command={command}
832
+ messages={controller.messages}
833
+ onCancel={() => controller.settleConfirmation(false)}
834
+ onConfirm={() => controller.settleConfirmation(true)}
835
+ returnFocusRef={controller.textareaRef}
836
+ />
837
+ );
838
+ }
839
+
840
+ export type ComposerFooterProps = ComponentPropsWithoutRef<"div">;
841
+
842
+ export const Footer = forwardRef<HTMLDivElement, ComposerFooterProps>(function ComposerFooter(
843
+ { className, ...props },
844
+ ref,
845
+ ) {
846
+ return (
847
+ <div
848
+ {...props}
849
+ ref={ref}
850
+ className={cn("flex items-end gap-2 px-2.5 pb-2.5 pt-1", className)}
851
+ />
852
+ );
853
+ });
854
+
855
+ export type ComposerControlsProps = ComponentPropsWithoutRef<"span">;
856
+
857
+ export const Controls = forwardRef<HTMLSpanElement, ComposerControlsProps>(
858
+ function ComposerControls({ className, ...props }, ref) {
859
+ return (
860
+ <span
861
+ {...props}
862
+ ref={ref}
863
+ className={cn("flex min-w-0 flex-1 flex-wrap items-center gap-1.5", className)}
864
+ />
865
+ );
866
+ },
867
+ );
868
+
869
+ export type ComposerHintProps = ComponentPropsWithoutRef<"span">;
870
+
871
+ export const Hint = forwardRef<HTMLSpanElement, ComposerHintProps>(function ComposerHint(
872
+ { className, children, ...props },
873
+ ref,
874
+ ) {
875
+ const controller = useComposerController();
876
+ return (
877
+ <span
878
+ {...props}
879
+ ref={ref}
880
+ className={cn("min-w-0 flex-1 px-1.5 text-og-xs text-og-fg-subtle max-sm:hidden", className)}
881
+ >
882
+ {children ?? controller.messages.keyboardHint}
883
+ </span>
884
+ );
885
+ });
886
+
887
+ export type ComposerActionsProps = ComponentPropsWithoutRef<"span">;
888
+
889
+ export const Actions = forwardRef<HTMLSpanElement, ComposerActionsProps>(function ComposerActions(
890
+ { className, ...props },
891
+ ref,
892
+ ) {
893
+ return (
894
+ <span
895
+ {...props}
896
+ ref={ref}
897
+ className={cn("ml-auto flex shrink-0 items-center gap-1.5", className)}
898
+ />
899
+ );
900
+ });
901
+
902
+ type OwnedButtonProps = "type" | "onClick" | "disabled";
903
+ export type ComposerAttachButtonProps = Omit<
904
+ ButtonHTMLAttributes<HTMLButtonElement>,
905
+ OwnedButtonProps
906
+ > & {
907
+ accept?: string | undefined;
908
+ multiple?: boolean | undefined;
909
+ };
910
+
911
+ export const AttachButton = forwardRef<HTMLButtonElement, ComposerAttachButtonProps>(
912
+ function ComposerAttachButton(
913
+ { accept, multiple = true, className, "aria-label": ariaLabel, title, children, ...props },
914
+ ref,
915
+ ) {
916
+ const controller = useComposerController();
917
+ if (!controller.attachments) return null;
918
+ return (
919
+ <>
920
+ <input
921
+ ref={controller.fileInputRef}
922
+ type="file"
923
+ accept={accept}
924
+ multiple={multiple}
925
+ className="hidden"
926
+ onChange={controller.handleFileChange}
927
+ />
928
+ <button
929
+ {...props}
930
+ ref={ref}
931
+ type="button"
932
+ disabled={controller.disabled}
933
+ onClick={() => controller.fileInputRef.current?.click()}
934
+ aria-label={ariaLabel ?? controller.messages.attachFiles}
935
+ title={title ?? controller.messages.attachFiles}
936
+ className={cn(
937
+ "inline-flex size-8 items-center justify-center rounded-og-md",
938
+ "text-og-fg-muted transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
939
+ "disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-11",
940
+ className,
941
+ )}
942
+ >
943
+ {children ?? <PaperclipIcon className="size-4" />}
944
+ </button>
945
+ </>
946
+ );
947
+ },
948
+ );
949
+
950
+ export type ComposerModelPickerProps = {
951
+ models: ClientModel[];
952
+ value?: string | undefined;
953
+ onChange?: ((modelId: string) => void) | undefined;
954
+ label?: string | undefined;
955
+ className?: string | undefined;
956
+ };
957
+
958
+ export function ModelPicker({
959
+ models,
960
+ value,
961
+ onChange,
962
+ label,
963
+ className,
964
+ }: ComposerModelPickerProps) {
965
+ const controller = useComposerController();
966
+ return (
967
+ <ModelPickerView
968
+ models={models}
969
+ value={value}
970
+ onChange={(modelId) => onChange?.(modelId)}
971
+ disabled={controller.disabled}
972
+ label={label ?? controller.messages.modelLabel}
973
+ className={className}
974
+ />
975
+ );
976
+ }
977
+
978
+ export type ComposerPauseButtonProps = Omit<
979
+ ButtonHTMLAttributes<HTMLButtonElement>,
980
+ OwnedButtonProps
981
+ >;
982
+
983
+ export const PauseButton = forwardRef<HTMLButtonElement, ComposerPauseButtonProps>(
984
+ function ComposerPauseButton(
985
+ { className, "aria-label": ariaLabel, title, children, ...props },
986
+ ref,
987
+ ) {
988
+ const controller = useComposerController();
989
+ if (!controller.effectiveControl || controller.paused || !controller.hasControl) return null;
990
+ const busy = controller.pausing || controller.resuming;
991
+ return (
992
+ <button
993
+ {...props}
994
+ ref={mergeRefs(controller.pauseButtonRef, ref)}
995
+ type="button"
996
+ onClick={() => void controller.pause()}
997
+ disabled={busy}
998
+ aria-label={ariaLabel ?? controller.messages.pauseAriaLabel}
999
+ title={title ?? controller.messages.pauseTitle}
1000
+ className={cn(
1001
+ "inline-flex size-8 items-center justify-center rounded-og-md border border-og-border pointer-coarse:size-11",
1002
+ "bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
1003
+ "hover:border-og-status-waiting/50 hover:text-og-status-waiting",
1004
+ "disabled:opacity-50",
1005
+ className,
1006
+ )}
1007
+ >
1008
+ {children ??
1009
+ (busy ? (
1010
+ <LoaderCircleIcon className="size-3.5 animate-og-spin" />
1011
+ ) : (
1012
+ <PauseIcon className="size-3.5 fill-current" />
1013
+ ))}
1014
+ </button>
1015
+ );
1016
+ },
1017
+ );
1018
+
1019
+ export type ComposerSendButtonProps = Omit<
1020
+ ButtonHTMLAttributes<HTMLButtonElement>,
1021
+ OwnedButtonProps
1022
+ >;
1023
+
1024
+ export const SendButton = forwardRef<HTMLButtonElement, ComposerSendButtonProps>(
1025
+ function ComposerSendButton(
1026
+ { className, "aria-label": ariaLabel, title, children, ...props },
1027
+ ref,
1028
+ ) {
1029
+ const controller = useComposerController();
1030
+ return (
1031
+ <button
1032
+ {...props}
1033
+ ref={ref}
1034
+ type="button"
1035
+ onClick={() => void controller.submit("queue")}
1036
+ disabled={!controller.canSubmit}
1037
+ aria-label={
1038
+ ariaLabel ??
1039
+ (controller.paused
1040
+ ? controller.messages.sendAndResumeAriaLabel
1041
+ : controller.messages.sendMessageAriaLabel)
1042
+ }
1043
+ title={
1044
+ title ??
1045
+ (controller.paused
1046
+ ? controller.messages.sendAndResumeTitle
1047
+ : controller.messages.sendTitle)
1048
+ }
1049
+ className={cn(
1050
+ "inline-flex size-8 items-center justify-center rounded-og-md pointer-coarse:size-11",
1051
+ "bg-og-accent text-og-accent-fg shadow-og-sm",
1052
+ "transition-[background-color,transform,opacity] duration-150 ease-og-spring",
1053
+ "hover:bg-og-accent-strong active:scale-95",
1054
+ "disabled:cursor-not-allowed disabled:bg-og-surface-3 disabled:text-og-fg-subtle disabled:shadow-none",
1055
+ className,
1056
+ )}
1057
+ >
1058
+ {children ??
1059
+ (controller.sending ? (
1060
+ <LoaderCircleIcon className="size-4 animate-og-spin" />
1061
+ ) : (
1062
+ <ArrowUpIcon className="size-4" />
1063
+ ))}
1064
+ </button>
1065
+ );
1066
+ },
1067
+ );
1068
+
1069
+ export function Help() {
1070
+ const controller = useComposerController();
1071
+ return (
1072
+ <AnimatePresence>
1073
+ {controller.helpOpen ? (
1074
+ <HelpPanel
1075
+ commands={controller.helpCommands}
1076
+ messages={controller.messages}
1077
+ onClose={() => controller.setHelpOpen(false)}
1078
+ />
1079
+ ) : null}
1080
+ </AnimatePresence>
1081
+ );
1082
+ }
1083
+
1084
+ export function Status() {
1085
+ const controller = useComposerController();
1086
+ return (
1087
+ <>
1088
+ <AnimatePresence>
1089
+ {controller.activeNotice ? (
1090
+ <motion.p
1091
+ initial={{ opacity: 0, height: 0 }}
1092
+ animate={{ opacity: 1, height: "auto" }}
1093
+ exit={{ opacity: 0, height: 0 }}
1094
+ className={cn(
1095
+ "overflow-hidden px-1 pt-1.5 text-xs",
1096
+ controller.activeNotice.tone === "ok" ? "text-og-fg-muted" : "text-og-status-failed",
1097
+ )}
1098
+ >
1099
+ {controller.activeNotice.message}
1100
+ </motion.p>
1101
+ ) : null}
1102
+ </AnimatePresence>
1103
+ {controller.draftConflict ? (
1104
+ <div className="mt-1.5 flex flex-wrap items-center gap-2 px-1 text-og-xs text-og-status-failed">
1105
+ <span className="min-w-0 flex-1">{controller.messages.draftConflict}</span>
1106
+ <button
1107
+ type="button"
1108
+ className="underline underline-offset-2"
1109
+ onClick={() => void controller.resolveDraftConflict?.("use_remote")}
1110
+ >
1111
+ {controller.messages.useOtherDraft}
1112
+ </button>
1113
+ <button
1114
+ type="button"
1115
+ className="font-medium underline underline-offset-2"
1116
+ onClick={() => void controller.resolveDraftConflict?.("keep_mine")}
1117
+ >
1118
+ {controller.messages.keepMine}
1119
+ </button>
1120
+ </div>
1121
+ ) : controller.draftSaving ? (
1122
+ <p className="px-1 pt-1 text-og-xs text-og-fg-subtle">{controller.messages.savingDraft}</p>
1123
+ ) : null}
1124
+ </>
1125
+ );
1126
+ }
1127
+
1128
+ function ComposerAnnouncements() {
1129
+ const controller = useComposerController();
1130
+ return (
1131
+ <div className="sr-only">
1132
+ {controller.activeNotice ? (
1133
+ <p role={controller.activeNotice.tone === "error" ? "alert" : "status"}>
1134
+ {controller.activeNotice.message}
1135
+ </p>
1136
+ ) : null}
1137
+ {controller.draftConflict ? (
1138
+ <p role="alert">{controller.messages.draftConflict}</p>
1139
+ ) : controller.draftSaving ? (
1140
+ <p role="status">{controller.messages.savingDraft}</p>
1141
+ ) : null}
1142
+ </div>
1143
+ );
1144
+ }
1145
+
1146
+ function mergeRefs<T>(...refs: Array<Ref<T> | undefined>): (node: T | null) => void {
1147
+ return (node) => {
1148
+ for (const ref of refs) {
1149
+ if (typeof ref === "function") ref(node);
1150
+ else if (ref) ref.current = node;
1151
+ }
1152
+ };
1153
+ }
1154
+
1155
+ function WorkstreamPausedStrip({
1156
+ control,
1157
+ queuedAheadCount,
1158
+ open,
1159
+ busy,
1160
+ sending,
1161
+ canControlWorkspace,
1162
+ controlLinks,
1163
+ messages,
1164
+ onOpenChange,
1165
+ onResume,
1166
+ onResumeOption,
1167
+ }: {
1168
+ control: EffectiveSessionControl;
1169
+ queuedAheadCount: number;
1170
+ open: boolean;
1171
+ busy: boolean;
1172
+ sending: boolean;
1173
+ canControlWorkspace: boolean;
1174
+ controlLinks: ComposerControlLinks | undefined;
1175
+ messages: ChatComposerMessages;
1176
+ onOpenChange: (open: boolean) => void;
1177
+ onResume: () => void;
1178
+ onResumeOption: (option: EffectiveSessionControl["resumeOptions"][number]) => void;
1179
+ }) {
1180
+ const blocker = control.primaryBlocker;
1181
+ const cause =
1182
+ blocker?.kind === "workspace"
1183
+ ? messages.workspacePaused
1184
+ : control.directState === "paused"
1185
+ ? messages.pausedHere
1186
+ : messages.pausedBy(blocker?.displayName ?? messages.parentBlocker);
1187
+ const primary = control.resumeOptions.find((option) => option.scope === "selected");
1188
+ const broaderOptions = control.resumeOptions.filter(
1189
+ (option) =>
1190
+ option !== primary &&
1191
+ option.scope !== "selected" &&
1192
+ (option.scope !== "workspace" || canControlWorkspace),
1193
+ );
1194
+ return (
1195
+ <div className="border-b border-og-status-waiting/25 bg-og-status-waiting/[0.07]">
1196
+ <div className="flex min-w-0 items-center gap-2 px-3 py-2">
1197
+ <PauseIcon className="size-3.5 shrink-0 text-og-status-waiting" />
1198
+ <button
1199
+ type="button"
1200
+ className="min-w-0 flex-1 text-left"
1201
+ onClick={() => onOpenChange(!open)}
1202
+ aria-expanded={open}
1203
+ >
1204
+ <span className="block truncate text-og-xs font-medium text-og-fg">{cause}</span>
1205
+ <span className="block truncate text-[11px] text-og-fg-muted">
1206
+ {queuedAheadCount > 0
1207
+ ? messages.queuedAhead(queuedAheadCount)
1208
+ : sending
1209
+ ? messages.resumingAndSending
1210
+ : messages.nextMessageResumes}
1211
+ </span>
1212
+ </button>
1213
+ <button
1214
+ type="button"
1215
+ aria-label={messages.resumeThisWorkstream}
1216
+ className="inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-og-md border border-og-status-waiting/35 bg-og-surface-1 px-2.5 text-og-xs font-medium text-og-fg hover:bg-og-surface-2 pointer-coarse:min-h-11"
1217
+ disabled={busy}
1218
+ onClick={onResume}
1219
+ >
1220
+ {busy ? (
1221
+ <LoaderCircleIcon className="size-3.5 animate-og-spin" />
1222
+ ) : (
1223
+ <PlayIcon className="size-3.5 fill-current" />
1224
+ )}
1225
+ <span className="hidden min-[400px]:inline">{messages.resumeThisWorkstream}</span>
1226
+ <span className="min-[400px]:hidden">{messages.resumeShort}</span>
1227
+ </button>
1228
+ <button
1229
+ type="button"
1230
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-og-md text-og-fg-muted hover:bg-og-surface-2 pointer-coarse:size-11"
1231
+ onClick={() => onOpenChange(!open)}
1232
+ aria-label={open ? messages.hidePauseDetails : messages.showPauseDetails}
1233
+ >
1234
+ <ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
1235
+ </button>
1236
+ </div>
1237
+ {open ? (
1238
+ <div className="border-t border-og-status-waiting/20 px-3 pb-3 pt-2">
1239
+ <ul className="grid gap-2" aria-label={messages.pauseReasonsLabel}>
1240
+ {control.blockers.map((entry, index) => (
1241
+ <li
1242
+ key={`${entry.kind}-${entry.sessionId ?? "workspace"}-${entry.revision}`}
1243
+ className="text-og-xs"
1244
+ >
1245
+ <span className="font-medium text-og-fg">
1246
+ {index === 0 ? messages.pausedByLabel : messages.alsoPausedByLabel}
1247
+ </span>
1248
+ {blockerHref(entry, controlLinks) ? (
1249
+ <a
1250
+ href={blockerHref(entry, controlLinks)}
1251
+ className="font-medium text-og-fg underline decoration-og-border-strong underline-offset-2 hover:text-og-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-ring/40"
1252
+ >
1253
+ {entry.displayName}
1254
+ </a>
1255
+ ) : (
1256
+ <span className="font-medium text-og-fg">{entry.displayName}</span>
1257
+ )}
1258
+ {entry.reason ? <span className="text-og-fg-muted"> · {entry.reason}</span> : null}
1259
+ {entry.actor ? <span className="text-og-fg-subtle"> · {entry.actor}</span> : null}
1260
+ {entry.changedAt ? (
1261
+ <span className="text-og-fg-subtle">
1262
+ {" "}
1263
+ · {messages.formatRelativeTime(entry.changedAt)}
1264
+ </span>
1265
+ ) : null}
1266
+ </li>
1267
+ ))}
1268
+ </ul>
1269
+ {broaderOptions.length > 0 ? (
1270
+ <div className="mt-2 flex flex-wrap gap-1.5">
1271
+ {broaderOptions.map((option) => (
1272
+ <button
1273
+ key={`${option.scope}-${option.targetId ?? "selected"}`}
1274
+ type="button"
1275
+ disabled={busy}
1276
+ title={option.impactCopy}
1277
+ onClick={() => onResumeOption(option)}
1278
+ 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"
1279
+ >
1280
+ <span className="block font-medium">
1281
+ {option.scope === "workspace"
1282
+ ? messages.resumeWorkspace
1283
+ : messages.resumeFromSession}
1284
+ </span>
1285
+ <span className="block text-[10px] text-og-fg-subtle">
1286
+ {option.selectedStateAfter === "active"
1287
+ ? messages.sessionCanRun
1288
+ : messages.stillPausedBy(
1289
+ option.remainingPrimaryBlocker?.displayName ?? messages.narrowerPause,
1290
+ )}
1291
+ </span>
1292
+ </button>
1293
+ ))}
1294
+ </div>
1295
+ ) : null}
1296
+ </div>
1297
+ ) : null}
1298
+ </div>
1299
+ );
1300
+ }
1301
+
1302
+ function blockerHref(
1303
+ blocker: EffectiveSessionControl["blockers"][number],
1304
+ links: ComposerControlLinks | undefined,
1305
+ ): string | undefined {
1306
+ if (blocker.kind === "workspace") return links?.workspaceHref;
1307
+ return blocker.sessionId ? links?.sessionHref?.(blocker.sessionId) : undefined;
1308
+ }
1309
+
1310
+ function RestoredResourceChips({
1311
+ resources,
1312
+ messages,
1313
+ onRemove,
1314
+ }: {
1315
+ resources: ComposerState["restoredResources"];
1316
+ messages: ChatComposerMessages;
1317
+ onRemove: (index: number) => void;
1318
+ }) {
1319
+ return (
1320
+ <div
1321
+ className="flex flex-wrap gap-1.5 border-b border-og-border px-3 py-2"
1322
+ aria-label={messages.restoredResourcesLabel}
1323
+ >
1324
+ {resources.map((resource, index) => (
1325
+ <span
1326
+ key={`${resource.kind}-${resource.kind === "file" ? resource.fileId : resource.uri}`}
1327
+ className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-og-md border border-og-border bg-og-surface-2 px-2 py-1 text-og-xs text-og-fg-muted"
1328
+ >
1329
+ <FileIcon className="size-3 shrink-0" />
1330
+ <span className="truncate">
1331
+ {resource.kind === "file" ? messages.restoredFile(resource.fileId) : resource.uri}
1332
+ </span>
1333
+ <button
1334
+ type="button"
1335
+ className="shrink-0 hover:text-og-fg"
1336
+ onClick={() => onRemove(index)}
1337
+ aria-label={messages.removeRestoredResource(index)}
1338
+ >
1339
+ <XIcon className="size-3" />
1340
+ </button>
1341
+ </span>
1342
+ ))}
1343
+ </div>
1344
+ );
1345
+ }
1346
+
1347
+ function ConfirmBar({
1348
+ command,
1349
+ messages,
1350
+ onCancel,
1351
+ onConfirm,
1352
+ returnFocusRef,
1353
+ }: {
1354
+ command: SlashCommand;
1355
+ messages: ChatComposerMessages;
1356
+ onCancel: () => void;
1357
+ onConfirm: () => void;
1358
+ returnFocusRef?: RefObject<HTMLTextAreaElement | null> | undefined;
1359
+ }) {
1360
+ const confirmRef = useRef<HTMLButtonElement | null>(null);
1361
+ const descriptionId = useId();
1362
+ useEffect(() => {
1363
+ const returnTo = returnFocusRef?.current ?? null;
1364
+ confirmRef.current?.focus();
1365
+ return () => returnTo?.focus();
1366
+ }, [returnFocusRef]);
1367
+ return (
1368
+ <div
1369
+ role="alertdialog"
1370
+ aria-label={messages.confirmCommand(command.name)}
1371
+ aria-describedby={descriptionId}
1372
+ data-testid="danger-confirm"
1373
+ onKeyDown={(event) => {
1374
+ if (event.key === "Escape") {
1375
+ event.preventDefault();
1376
+ event.stopPropagation();
1377
+ onCancel();
1378
+ }
1379
+ }}
1380
+ className="flex items-end justify-between gap-2 px-2.5 pb-2.5 pt-1"
1381
+ >
1382
+ <span id={descriptionId} className="min-w-0 flex-1 px-1.5 text-og-sm text-og-status-failed">
1383
+ {messages.confirmDescription(command)}
1384
+ </span>
1385
+ <span className="flex shrink-0 items-center gap-1.5">
1386
+ <button
1387
+ type="button"
1388
+ onClick={onCancel}
1389
+ className="rounded-og-md border border-og-border bg-og-surface-2 px-2.5 py-1 text-og-sm text-og-fg-muted hover:bg-og-surface-3 pointer-coarse:min-h-11"
1390
+ >
1391
+ {messages.cancel}
1392
+ </button>
1393
+ <button
1394
+ ref={confirmRef}
1395
+ type="button"
1396
+ onClick={onConfirm}
1397
+ className="rounded-og-md border border-og-status-failed/50 bg-og-status-failed/15 px-2.5 py-1 text-og-sm text-og-status-failed hover:bg-og-status-failed/25 pointer-coarse:min-h-11"
1398
+ >
1399
+ {messages.runCommand(command.name)}
1400
+ </button>
1401
+ </span>
1402
+ </div>
1403
+ );
1404
+ }
1405
+
1406
+ function AttachmentChips({
1407
+ attachments,
1408
+ messages,
1409
+ onRemove,
1410
+ onRetry,
1411
+ }: {
1412
+ attachments: UseFileAttachmentsResult["attachments"];
1413
+ messages: ChatComposerMessages;
1414
+ onRemove: (id: string) => void;
1415
+ onRetry?: ((id: string) => void) | undefined;
1416
+ }) {
1417
+ return (
1418
+ <div className="flex flex-wrap gap-2 border-b border-og-border px-3 py-2">
1419
+ {attachments.map((attachment) => {
1420
+ const failed = attachment.status === "failed";
1421
+ const statusText =
1422
+ attachment.status === "uploading"
1423
+ ? messages.uploading
1424
+ : failed
1425
+ ? attachment.error || messages.uploadFailed
1426
+ : messages.formatBytes(attachment.sizeBytes);
1427
+ return (
1428
+ <div
1429
+ key={attachment.id}
1430
+ className={cn(
1431
+ "flex min-w-0 max-w-[240px] items-center gap-2 rounded-og-md border px-2 py-1.5 text-og-sm",
1432
+ failed
1433
+ ? "border-og-status-failed/40 bg-og-status-failed/10"
1434
+ : "border-og-border bg-og-surface-2",
1435
+ )}
1436
+ >
1437
+ {attachment.previewUrl ? (
1438
+ <img
1439
+ src={attachment.previewUrl}
1440
+ alt=""
1441
+ className="size-8 shrink-0 rounded object-cover"
1442
+ />
1443
+ ) : attachment.contentType.startsWith("image/") ? (
1444
+ <ImageIcon className="size-4 shrink-0 text-og-fg-muted" />
1445
+ ) : (
1446
+ <FileIcon className="size-4 shrink-0 text-og-fg-muted" />
1447
+ )}
1448
+ <div className="min-w-0 flex-1">
1449
+ <div className="truncate font-medium text-og-fg">{attachment.name}</div>
1450
+ <div
1451
+ className={cn(
1452
+ "truncate text-og-xs",
1453
+ failed ? "text-og-status-failed" : "text-og-fg-subtle",
1454
+ )}
1455
+ title={failed ? statusText : undefined}
1456
+ >
1457
+ {statusText}
1458
+ </div>
1459
+ </div>
1460
+ {attachment.status === "uploading" ? (
1461
+ <LoaderCircleIcon className="size-3.5 shrink-0 animate-og-spin" />
1462
+ ) : null}
1463
+ {failed && onRetry ? (
1464
+ <button
1465
+ type="button"
1466
+ onClick={() => onRetry(attachment.id)}
1467
+ 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"
1468
+ aria-label={messages.retryAttachment(attachment.name)}
1469
+ title={messages.retryUpload}
1470
+ >
1471
+ <RotateCwIcon className="size-3.5" />
1472
+ </button>
1473
+ ) : null}
1474
+ <button
1475
+ type="button"
1476
+ onClick={() => onRemove(attachment.id)}
1477
+ 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"
1478
+ aria-label={messages.removeAttachment(attachment.name)}
1479
+ >
1480
+ <XIcon className="size-3.5" />
1481
+ </button>
1482
+ </div>
1483
+ );
1484
+ })}
1485
+ </div>
1486
+ );
1487
+ }
1488
+
1489
+ function HelpPanel({
1490
+ commands,
1491
+ messages,
1492
+ onClose,
1493
+ }: {
1494
+ commands: readonly SlashCommand[];
1495
+ messages: ChatComposerMessages;
1496
+ onClose: () => void;
1497
+ }) {
1498
+ return (
1499
+ <motion.div
1500
+ initial={{ opacity: 0, height: 0 }}
1501
+ animate={{ opacity: 1, height: "auto" }}
1502
+ exit={{ opacity: 0, height: 0 }}
1503
+ className="mt-2 overflow-hidden rounded-og-lg border border-og-border bg-og-surface-2"
1504
+ >
1505
+ <div className="flex items-center justify-between border-b border-og-border px-3 py-1.5">
1506
+ <span className="text-og-sm font-medium text-og-fg">{messages.commands}</span>
1507
+ <button
1508
+ type="button"
1509
+ onClick={onClose}
1510
+ className="text-og-xs text-og-fg-subtle hover:text-og-fg"
1511
+ >
1512
+ {messages.close}
1513
+ </button>
1514
+ </div>
1515
+ <ul className="py-1">
1516
+ {commands.map((command) => {
1517
+ const hint = argHint(command.args);
1518
+ return (
1519
+ <li key={command.name} className="flex items-baseline gap-2 px-3 py-1">
1520
+ <span className="font-mono text-og-sm text-og-accent">
1521
+ /{command.name}
1522
+ {hint ? <span className="ml-1 text-og-fg-subtle">{hint}</span> : null}
1523
+ </span>
1524
+ <span className="text-og-sm text-og-fg-muted">{command.description}</span>
1525
+ {command.danger ? (
1526
+ <span className="ml-auto rounded-og-xs bg-og-status-failed/15 px-1 text-og-xs uppercase tracking-wide text-og-status-failed">
1527
+ {messages.danger}
1528
+ </span>
1529
+ ) : null}
1530
+ </li>
1531
+ );
1532
+ })}
1533
+ </ul>
1534
+ </motion.div>
1535
+ );
1536
+ }