@opengeni/react 0.15.0 → 0.23.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 +110 -10
- package/dist/{chunk-TOJR776I.js → chunk-6XUS5VFM.js} +45 -369
- package/dist/chunk-6XUS5VFM.js.map +1 -0
- package/dist/chunk-CATATY77.js +2393 -0
- package/dist/chunk-CATATY77.js.map +1 -0
- package/dist/chunk-I3BJZIG5.js +439 -0
- package/dist/chunk-I3BJZIG5.js.map +1 -0
- package/dist/chunk-TK7G6XLT.js +18 -0
- package/dist/chunk-TK7G6XLT.js.map +1 -0
- package/dist/chunk-UAHPKLB6.js +2237 -0
- package/dist/chunk-UAHPKLB6.js.map +1 -0
- package/dist/chunk-UQS7OQDE.js +591 -0
- package/dist/chunk-UQS7OQDE.js.map +1 -0
- package/dist/chunk-YDNWMKXO.js +102 -0
- package/dist/chunk-YDNWMKXO.js.map +1 -0
- package/dist/composer-CVm5uoUI.d.ts +585 -0
- package/dist/composer.d.ts +6 -0
- package/dist/composer.js +56 -0
- package/dist/composer.js.map +1 -0
- package/dist/index.d.ts +1288 -1785
- package/dist/index.js +7140 -8817
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +3 -1
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
- package/dist/queue-surface-implementation-3UW3MIIR.js +890 -0
- package/dist/queue-surface-implementation-3UW3MIIR.js.map +1 -0
- package/dist/session-Db00TRwl.d.ts +446 -0
- package/dist/session-context-rgrfElMl.d.ts +58 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +54 -0
- package/dist/session.js.map +1 -0
- package/dist/use-composer-By1wn3MM.d.ts +99 -0
- package/package.json +20 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +44 -0
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +94 -993
- package/src/components/code-editor.tsx +104 -26
- package/src/components/command-palette.tsx +8 -2
- package/src/components/composer-transcription-control.tsx +211 -0
- package/src/components/composer.tsx +1536 -0
- package/src/components/desktop-viewer.tsx +3 -3
- package/src/components/enrollment-consent.tsx +2 -2
- package/src/components/file-browser.tsx +367 -29
- package/src/components/human-input-form.tsx +417 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machines-dashboard.tsx +3 -3
- package/src/components/message-timeline.tsx +205 -25
- package/src/components/model-picker.tsx +12 -3
- package/src/components/pierre-diff.tsx +32 -6
- package/src/components/queue-draft-policy.ts +18 -0
- package/src/components/queue-surface-implementation.tsx +1037 -0
- package/src/components/queue-surface-state.tsx +94 -0
- package/src/components/queue-surface.tsx +42 -559
- package/src/components/sandbox-files.tsx +138 -14
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +339 -119
- package/src/components/workbench-changes.tsx +84 -33
- package/src/components/workspace-dock.tsx +188 -18
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +385 -104
- package/src/hooks/use-human-input.ts +131 -0
- package/src/hooks/use-machine-chip.ts +2 -2
- package/src/hooks/use-machines.ts +27 -11
- package/src/hooks/use-sandbox-files.ts +310 -62
- package/src/hooks/use-sandbox-git.ts +154 -40
- package/src/hooks/use-sandbox-terminal.ts +28 -6
- package/src/hooks/use-session-capabilities.ts +38 -9
- package/src/hooks/use-session-control.ts +37 -8
- package/src/hooks/use-session-events.ts +522 -48
- package/src/hooks/use-session-mcp-approval-policy.ts +161 -0
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +134 -66
- package/src/hooks/use-windowed-sections.ts +2 -2
- package/src/hooks/use-workspace-capture.ts +146 -40
- package/src/hooks/use-workspace-edit.ts +50 -14
- package/src/hooks/use-workspace-sessions.ts +3 -0
- package/src/human-input.ts +72 -0
- package/src/index.ts +85 -6
- package/src/lib/noto-sans-arabic-loader.ts +21 -0
- package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
- package/src/lib/use-portal-token-style.ts +51 -0
- package/src/lib/use-unicode-fonts.ts +57 -0
- package/src/provider.tsx +4 -69
- package/src/session-context.ts +160 -0
- package/src/session.ts +89 -0
- package/src/timeline/parsers.ts +35 -0
- package/src/timeline/projection.ts +12 -4
- package/src/timeline/tool-renderers.tsx +41 -0
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +10 -9
- package/styles/index.css +56 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-TOJR776I.js.map +0 -1
- 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
|
+
}
|