@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
|
@@ -1,46 +1,40 @@
|
|
|
1
1
|
import type { ClientModel, EffectiveSessionControl } from "@opengeni/sdk";
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
useCallback,
|
|
17
|
-
useEffect,
|
|
18
|
-
useId,
|
|
19
|
-
useMemo,
|
|
20
|
-
useRef,
|
|
21
|
-
useState,
|
|
22
|
-
type ChangeEvent,
|
|
23
|
-
type ClipboardEvent,
|
|
24
|
-
type DragEvent,
|
|
25
|
-
type KeyboardEvent,
|
|
26
|
-
type ReactNode,
|
|
27
|
-
type RefObject,
|
|
28
|
-
} from "react";
|
|
29
|
-
import { argHint } from "../commands/registry";
|
|
30
|
-
import type { Notice, SlashCommand } from "../commands/types";
|
|
2
|
+
import type { ClipboardEvent, ReactNode } from "react";
|
|
3
|
+
import type { SlashCommand } from "../commands/types";
|
|
31
4
|
import type { ComposerState } from "../hooks/use-composer";
|
|
32
|
-
import { shouldSteerOnKey, shouldSubmitOnKey } from "../hooks/use-composer";
|
|
33
5
|
import type { UseFileAttachmentsResult } from "../hooks/use-file-attachments";
|
|
34
|
-
import {
|
|
6
|
+
import type { SlashCommandContext } from "../hooks/use-slash-commands";
|
|
7
|
+
import {
|
|
8
|
+
Actions,
|
|
9
|
+
AttachButton,
|
|
10
|
+
Attachments,
|
|
11
|
+
CommandPalette,
|
|
12
|
+
Confirmation,
|
|
13
|
+
Controls,
|
|
14
|
+
Footer,
|
|
15
|
+
Frame,
|
|
16
|
+
Help,
|
|
17
|
+
Hint,
|
|
18
|
+
Input,
|
|
19
|
+
ModelPicker,
|
|
20
|
+
OPEN_WORKSTREAM_CONTROL_EVENT,
|
|
21
|
+
PauseButton,
|
|
22
|
+
PausedState,
|
|
23
|
+
RestoredResources,
|
|
24
|
+
Root,
|
|
25
|
+
SendButton,
|
|
26
|
+
Status,
|
|
27
|
+
Surface,
|
|
28
|
+
useChatComposerController,
|
|
29
|
+
type ChatComposerMessages,
|
|
30
|
+
type ComposerControlLinks,
|
|
31
|
+
} from "./composer";
|
|
35
32
|
import {
|
|
36
|
-
|
|
37
|
-
type
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
import { formatBytes, formatRelativeTime } from "../lib/format";
|
|
42
|
-
import { CommandPalette } from "./command-palette";
|
|
43
|
-
import { ModelPicker } from "./model-picker";
|
|
33
|
+
ComposerTranscriptionControl,
|
|
34
|
+
type ComposerTranscriptionControlProps,
|
|
35
|
+
} from "./composer-transcription-control";
|
|
36
|
+
|
|
37
|
+
export { OPEN_WORKSTREAM_CONTROL_EVENT };
|
|
44
38
|
|
|
45
39
|
export type ChatComposerProps = {
|
|
46
40
|
composer: ComposerState;
|
|
@@ -51,86 +45,51 @@ export type ChatComposerProps = {
|
|
|
51
45
|
/** Whether broader Workspace Resume is authorized for this viewer. */
|
|
52
46
|
canControlWorkspace?: boolean | undefined;
|
|
53
47
|
/** Optional host routes used to navigate from effective Pause blockers. */
|
|
54
|
-
controlLinks?:
|
|
55
|
-
| {
|
|
56
|
-
workspaceHref?: string | undefined;
|
|
57
|
-
sessionHref?: ((sessionId: string) => string) | undefined;
|
|
58
|
-
}
|
|
59
|
-
| undefined;
|
|
48
|
+
controlLinks?: ComposerControlLinks | undefined;
|
|
60
49
|
placeholder?: string | undefined;
|
|
61
50
|
disabled?: boolean | undefined;
|
|
62
51
|
autoFocus?: boolean | undefined;
|
|
63
52
|
/** Replaces the default keyboard hint under the field. */
|
|
64
53
|
hint?: string | undefined;
|
|
65
|
-
/** App controls
|
|
54
|
+
/** App controls in the footer row, replacing the hint. */
|
|
66
55
|
controlsStart?: ReactNode | undefined;
|
|
67
|
-
/**
|
|
56
|
+
/** Provider-neutral speech capability. Provider configuration stays in workspace settings. */
|
|
57
|
+
transcription?: ComposerTranscriptionControlProps | undefined;
|
|
58
|
+
/** Content rendered above the textarea, inside the field chrome. */
|
|
68
59
|
header?: ReactNode | undefined;
|
|
69
|
-
/** Paste hook
|
|
60
|
+
/** Paste hook composed with the attachment paste path. */
|
|
70
61
|
onPaste?: ((event: ClipboardEvent<HTMLTextAreaElement>) => void) | undefined;
|
|
71
|
-
/**
|
|
72
|
-
* Opt-in file attachments. When supplied (e.g. from {@link useFileAttachments}),
|
|
73
|
-
* the composer renders a built-in attach button (prepended to `controlsStart`),
|
|
74
|
-
* an attachment-chips strip (above the textarea, before any host `header`),
|
|
75
|
-
* routes paste through `addFromPaste` (image/* filter lives in the hook), and
|
|
76
|
-
* gates send while `uploading` so a message never departs without its files.
|
|
77
|
-
* Absent → no attachment UI renders and the composer behaves exactly as before.
|
|
78
|
-
*/
|
|
62
|
+
/** Opt-in file attachment state, typically from `useFileAttachments`. */
|
|
79
63
|
attachments?: UseFileAttachmentsResult | undefined;
|
|
80
|
-
/**
|
|
81
|
-
* Opt-in model picker. When supplied (e.g. from {@link useAvailableModels}),
|
|
82
|
-
* the composer renders a {@link ModelPicker} at the start of `controlsStart`
|
|
83
|
-
* so the operator can choose which host-exposed model serves the next message.
|
|
84
|
-
* The host owns the selection (`selectedModel`/`onSelectModel`) and is
|
|
85
|
-
* responsible for threading it into the composer's `sendExtras` (typically
|
|
86
|
-
* `useComposer({ sendExtras: () => ({ model }) })`) so `composeSendInput`
|
|
87
|
-
* carries it. Absent → no picker renders and the composer behaves as before.
|
|
88
|
-
*/
|
|
64
|
+
/** Opt-in model picker choices. */
|
|
89
65
|
models?: ClientModel[] | undefined;
|
|
90
|
-
/** The currently selected model id (the picker's controlled value). */
|
|
91
66
|
selectedModel?: string | undefined;
|
|
92
|
-
/** Called with the chosen model id when the operator picks one. */
|
|
93
67
|
onSelectModel?: ((modelId: string) => void) | undefined;
|
|
94
68
|
className?: string | undefined;
|
|
95
|
-
/**
|
|
96
|
-
* Slash-command palette. Defaults to the built-in {@link defaultCommands};
|
|
97
|
-
* apps concat their own. Backward-compatible: when `commandContext` is absent
|
|
98
|
-
* the palette is inert and behavior is identical to before.
|
|
99
|
-
*/
|
|
100
69
|
commands?: readonly SlashCommand[] | undefined;
|
|
101
|
-
/**
|
|
102
|
-
* Wiring the palette needs to run server commands and gate visibility. The
|
|
103
|
-
* composer supplies notice/openHelp/clearView/confirm internally.
|
|
104
|
-
*/
|
|
105
70
|
commandContext?: SlashCommandContext | undefined;
|
|
106
|
-
/** Reset the local timeline view (the /clear-view command target). */
|
|
107
71
|
onClearView?: (() => void) | undefined;
|
|
72
|
+
/** Partial overrides for all composer-owned visible and accessible copy. */
|
|
73
|
+
messages?: Partial<ChatComposerMessages> | undefined;
|
|
108
74
|
};
|
|
109
75
|
|
|
110
|
-
export const OPEN_WORKSTREAM_CONTROL_EVENT = "opengeni:open-workstream-control";
|
|
111
|
-
|
|
112
76
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* running is legitimate steering, so send stays available too).
|
|
117
|
-
*
|
|
118
|
-
* Typing a leading "/" opens the slash-command palette — SESSION/OPERATOR
|
|
119
|
-
* controls (clear, compact, pause goal, help), never a structured channel to
|
|
120
|
-
* the agent. The palette is purely additive: with no `commandContext` it is
|
|
121
|
-
* inert and the composer behaves exactly as before.
|
|
77
|
+
* Batteries-included chat composer. This preset is assembled exclusively from
|
|
78
|
+
* the public controller and compound primitives exported by the composer
|
|
79
|
+
* subpath, so custom and default layouts share one behavioral implementation.
|
|
122
80
|
*/
|
|
123
81
|
export function ChatComposer({
|
|
124
82
|
composer,
|
|
125
83
|
effectiveControl,
|
|
126
|
-
queuedAheadCount
|
|
127
|
-
canControlWorkspace
|
|
84
|
+
queuedAheadCount,
|
|
85
|
+
canControlWorkspace,
|
|
128
86
|
controlLinks,
|
|
129
87
|
placeholder,
|
|
130
88
|
disabled,
|
|
131
89
|
autoFocus,
|
|
132
90
|
hint,
|
|
133
91
|
controlsStart,
|
|
92
|
+
transcription,
|
|
134
93
|
header,
|
|
135
94
|
onPaste,
|
|
136
95
|
attachments,
|
|
@@ -138,923 +97,65 @@ export function ChatComposer({
|
|
|
138
97
|
selectedModel,
|
|
139
98
|
onSelectModel,
|
|
140
99
|
className,
|
|
141
|
-
commands
|
|
100
|
+
commands,
|
|
142
101
|
commandContext,
|
|
143
102
|
onClearView,
|
|
103
|
+
messages,
|
|
144
104
|
}: ChatComposerProps) {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
window.requestAnimationFrame(() => {
|
|
157
|
-
document
|
|
158
|
-
.querySelector<HTMLButtonElement>('button[aria-label="Pause this workstream"]')
|
|
159
|
-
?.focus();
|
|
160
|
-
});
|
|
161
|
-
};
|
|
162
|
-
document.addEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl);
|
|
163
|
-
return () => document.removeEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl);
|
|
164
|
-
}, [paused]);
|
|
165
|
-
|
|
166
|
-
// Block sends while attachments are still uploading so a message never
|
|
167
|
-
// departs without the files the user attached to it. This gates BOTH the
|
|
168
|
-
// Enter-to-send path (which calls composer.send directly, bypassing canSend)
|
|
169
|
-
// and the send button — dropping either path could ship a fileless message.
|
|
170
|
-
const blockedByUpload = attachments?.uploading === true;
|
|
171
|
-
|
|
172
|
-
// A ready attachment makes a file-only message (empty draft) sendable. The
|
|
173
|
-
// composer (when wired with `sendExtras.resources`) already reflects this in
|
|
174
|
-
// `canSend`; we OR it in here too so send-enablement is correct even for a
|
|
175
|
-
// composer whose canSend doesn't know about attachments — and so this stays
|
|
176
|
-
// the single home of the attachment send-gate.
|
|
177
|
-
const hasReadyAttachment = (attachments?.readyResources.length ?? 0) > 0;
|
|
178
|
-
// The send affordance: text OR a ready attachment, never mid-upload or mid-send.
|
|
179
|
-
const canSend = (composer.canSend || hasReadyAttachment) && !blockedByUpload && !composer.sending;
|
|
180
|
-
|
|
181
|
-
// Drag-and-drop file attach: only a drop target when `attachments` is wired,
|
|
182
|
-
// and only reacts to drags that actually carry files (so it never hijacks
|
|
183
|
-
// normal text drag/drop). `dragging` drives the drop overlay.
|
|
184
|
-
const [dragging, setDragging] = useState(false);
|
|
185
|
-
const dragCarriesFiles = (event: { dataTransfer: DataTransfer | null }): boolean =>
|
|
186
|
-
event.dataTransfer != null && [...event.dataTransfer.types].includes("Files");
|
|
187
|
-
const handleDragOver = useCallback(
|
|
188
|
-
(event: DragEvent<HTMLDivElement>) => {
|
|
189
|
-
if (!attachments || !dragCarriesFiles(event)) {
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
// preventDefault marks this a valid drop target so the browser fires drop.
|
|
193
|
-
event.preventDefault();
|
|
194
|
-
setDragging(true);
|
|
195
|
-
},
|
|
196
|
-
[attachments],
|
|
197
|
-
);
|
|
198
|
-
const handleDragLeave = useCallback(
|
|
199
|
-
(event: DragEvent<HTMLDivElement>) => {
|
|
200
|
-
if (!attachments) {
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
// Ignore leaves bubbling from children: only clear when the pointer left
|
|
204
|
-
// the composer bounds entirely (the related target is outside it).
|
|
205
|
-
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
setDragging(false);
|
|
209
|
-
},
|
|
210
|
-
[attachments],
|
|
211
|
-
);
|
|
212
|
-
const handleDrop = useCallback(
|
|
213
|
-
(event: DragEvent<HTMLDivElement>) => {
|
|
214
|
-
if (!attachments || !dragCarriesFiles(event)) {
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
event.preventDefault();
|
|
218
|
-
setDragging(false);
|
|
219
|
-
// Same path the picker uses: addFiles accepts ALL files (no image filter).
|
|
220
|
-
if (event.dataTransfer.files.length > 0) {
|
|
221
|
-
attachments.addFiles(event.dataTransfer.files);
|
|
222
|
-
}
|
|
223
|
-
},
|
|
224
|
-
[attachments],
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
const [notice, setNotice] = useState<Notice | null>(null);
|
|
228
|
-
const [helpOpen, setHelpOpen] = useState(false);
|
|
229
|
-
const [confirmState, setConfirmState] = useState<ConfirmState>(null);
|
|
230
|
-
const listboxId = useId();
|
|
231
|
-
|
|
232
|
-
const resize = useCallback(() => {
|
|
233
|
-
const textarea = textareaRef.current;
|
|
234
|
-
if (!textarea) {
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
textarea.style.height = "0px";
|
|
238
|
-
textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`;
|
|
239
|
-
}, []);
|
|
240
|
-
|
|
241
|
-
useEffect(() => {
|
|
242
|
-
resize();
|
|
243
|
-
}, [composer.value, resize]);
|
|
244
|
-
|
|
245
|
-
// The UI affordances commands reach: surface a notice, open the help panel,
|
|
246
|
-
// reset the local view, and the danger confirm flow (resolves when the
|
|
247
|
-
// operator confirms/cancels in the confirm bar).
|
|
248
|
-
const handlers = useMemo(
|
|
249
|
-
() => ({
|
|
250
|
-
notice: (next: Notice) => {
|
|
251
|
-
setNotice(next);
|
|
252
|
-
composer.clearError();
|
|
253
|
-
},
|
|
254
|
-
openHelp: () => setHelpOpen(true),
|
|
255
|
-
// Report whether a view-reset was actually wired by the host: with no
|
|
256
|
-
// onClearView the command is a no-op and must say so (not a false success).
|
|
257
|
-
clearView: () => {
|
|
258
|
-
if (!onClearView) {
|
|
259
|
-
return false;
|
|
260
|
-
}
|
|
261
|
-
onClearView();
|
|
262
|
-
return true;
|
|
263
|
-
},
|
|
264
|
-
// The hook binds the command actually being run into confirm() (see
|
|
265
|
-
// use-slash-commands buildContext), so the confirm bar renders from THAT
|
|
266
|
-
// command — never a near-match highlighted in the palette. This is what
|
|
267
|
-
// keeps the destructive /clear from being mislabeled as /clear-view.
|
|
268
|
-
confirm: (command: SlashCommand) =>
|
|
269
|
-
new Promise<boolean>((resolve) => {
|
|
270
|
-
setConfirmState({
|
|
271
|
-
command,
|
|
272
|
-
resolve: (confirmed) => {
|
|
273
|
-
setConfirmState(null);
|
|
274
|
-
resolve(confirmed);
|
|
275
|
-
},
|
|
276
|
-
});
|
|
277
|
-
}),
|
|
278
|
-
}),
|
|
279
|
-
[composer, onClearView],
|
|
280
|
-
);
|
|
281
|
-
|
|
282
|
-
const palette = useSlashCommands({
|
|
105
|
+
const controller = useChatComposerController({
|
|
106
|
+
delivery: composer,
|
|
107
|
+
draft: composer,
|
|
108
|
+
control: composer,
|
|
109
|
+
effectiveControl,
|
|
110
|
+
queuedAheadCount,
|
|
111
|
+
canControlWorkspace,
|
|
112
|
+
controlLinks,
|
|
113
|
+
disabled,
|
|
114
|
+
attachments,
|
|
283
115
|
commands,
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
116
|
+
commandContext,
|
|
117
|
+
onClearView,
|
|
118
|
+
onPaste,
|
|
119
|
+
messages,
|
|
288
120
|
});
|
|
289
|
-
|
|
290
|
-
// The confirm bar renders from the command the hook is actually running —
|
|
291
|
-
// carried into confirmState by handlers.confirm — NOT a near-match that
|
|
292
|
-
// happens to be highlighted/active in the palette. (Re-deriving it from
|
|
293
|
-
// palette.items[palette.highlight] mislabeled the destructive /clear as the
|
|
294
|
-
// harmless /clear-view, since clear-view prefix-matches "clear" and sorts
|
|
295
|
-
// first.)
|
|
296
|
-
const pendingDangerCommand = confirmState ? confirmState.command : null;
|
|
297
|
-
|
|
298
|
-
const paletteEnabled = commandContext !== undefined;
|
|
299
|
-
|
|
300
|
-
// A slash-command draft must never be delivered to the agent as chat — it is
|
|
301
|
-
// an operator control, not a message. The palette consumes Enter while open,
|
|
302
|
-
// but after Escape the popover is closed yet the draft still matches a command;
|
|
303
|
-
// block the send path (here and on the send button) so "/clear" can't be sent.
|
|
304
|
-
const commandDraftBlocked = paletteEnabled && palette.isCommandDraft;
|
|
305
|
-
|
|
306
|
-
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
307
|
-
// Palette key handling runs FIRST and only when the palette is open; when
|
|
308
|
-
// closed it returns false and the existing send path is untouched.
|
|
309
|
-
if (paletteEnabled && palette.onKeyDown(event)) {
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
if (shouldSubmitOnKey(event)) {
|
|
313
|
-
event.preventDefault();
|
|
314
|
-
if (commandDraftBlocked) {
|
|
315
|
-
// Dismissed palette + a "/command" draft: don't send it as chat. Nudge
|
|
316
|
-
// the operator to re-open the palette (any edit re-opens it) or clear it.
|
|
317
|
-
setNotice({
|
|
318
|
-
tone: "error",
|
|
319
|
-
message:
|
|
320
|
-
"That's a slash command — press Enter in the command list to run it, or edit the line to send a message.",
|
|
321
|
-
});
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
if (blockedByUpload) {
|
|
325
|
-
// Files are still uploading: swallow the Enter so the message can't
|
|
326
|
-
// depart without them. The send button is disabled in the same state.
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
void (shouldSteerOnKey(event) ? composer.steer() : composer.send());
|
|
330
|
-
}
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
// The host's onPaste still fires (model/tool/whatever paste handling); when
|
|
334
|
-
// attachments are wired, also feed the clipboard through addFromPaste so the
|
|
335
|
-
// image/* filter (owned by the hook) attaches pasted images.
|
|
336
|
-
const handlePaste = useCallback(
|
|
337
|
-
(event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
338
|
-
onPaste?.(event);
|
|
339
|
-
attachments?.addFromPaste(event);
|
|
340
|
-
},
|
|
341
|
-
[onPaste, attachments],
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
const handleFileChange = useCallback(
|
|
345
|
-
(event: ChangeEvent<HTMLInputElement>) => {
|
|
346
|
-
if (event.target.files) {
|
|
347
|
-
attachments?.addFiles(event.target.files);
|
|
348
|
-
}
|
|
349
|
-
event.target.value = "";
|
|
350
|
-
},
|
|
351
|
-
[attachments],
|
|
352
|
-
);
|
|
353
|
-
|
|
354
|
-
const helpCommands = useMemo(
|
|
355
|
-
() =>
|
|
356
|
-
commands.filter((command) => {
|
|
357
|
-
if (command.permission && commandContext) {
|
|
358
|
-
const perms = commandContext.permissions;
|
|
359
|
-
return perms.includes(command.permission) || perms.includes("workspace:admin");
|
|
360
|
-
}
|
|
361
|
-
return true;
|
|
362
|
-
}),
|
|
363
|
-
[commands, commandContext],
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
const activeNotice =
|
|
367
|
-
notice ??
|
|
368
|
-
(composer.error
|
|
369
|
-
? {
|
|
370
|
-
tone: "error" as const,
|
|
371
|
-
message: /control changed|paused while/i.test(composer.error.message)
|
|
372
|
-
? "This workstream was paused while you were sending. Nothing was sent, and your draft is still here."
|
|
373
|
-
: composer.error.message || "Sending failed — your draft is still here. Try again.",
|
|
374
|
-
}
|
|
375
|
-
: null);
|
|
121
|
+
const hasControls = Boolean(attachments || models || controlsStart || transcription);
|
|
376
122
|
|
|
377
123
|
return (
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
{paletteEnabled ? (
|
|
386
|
-
<CommandPalette
|
|
387
|
-
open={palette.open && confirmState === null}
|
|
388
|
-
items={palette.items}
|
|
389
|
-
highlight={palette.highlight}
|
|
390
|
-
onHighlight={palette.setHighlight}
|
|
391
|
-
onRun={(index) => {
|
|
392
|
-
// Run the CLICKED row directly. We must not route a pointer click
|
|
393
|
-
// through runHighlighted: its exact-match override would re-resolve
|
|
394
|
-
// "/clear" to the destructive clear even when the operator clicked
|
|
395
|
-
// the harmless clear-view row. runAt honors the explicit selection.
|
|
396
|
-
palette.setHighlight(index);
|
|
397
|
-
void palette.runAt(index);
|
|
398
|
-
}}
|
|
399
|
-
argHintText={palette.activeArgHint}
|
|
400
|
-
listboxId={listboxId}
|
|
401
|
-
/>
|
|
402
|
-
) : null}
|
|
403
|
-
<div
|
|
404
|
-
// Drag-and-drop file attach lives on the field wrapper, but only when
|
|
405
|
-
// `attachments` is wired — without it the composer is not a drop target
|
|
406
|
-
// and behaves exactly as before.
|
|
407
|
-
onDragOver={attachments ? handleDragOver : undefined}
|
|
408
|
-
onDragLeave={attachments ? handleDragLeave : undefined}
|
|
409
|
-
onDrop={attachments ? handleDrop : undefined}
|
|
410
|
-
className={cn(
|
|
411
|
-
"relative rounded-og-lg border border-og-border bg-og-surface-1 shadow-og-sm",
|
|
412
|
-
"transition-[border-color,box-shadow] duration-200",
|
|
413
|
-
"focus-within:border-og-accent/60 focus-within:shadow-og-glow",
|
|
414
|
-
// While files are dragged over, swap to a dashed accent border to
|
|
415
|
-
// signal a live drop target (the overlay carries the label).
|
|
416
|
-
dragging && "border-dashed border-og-accent",
|
|
417
|
-
)}
|
|
418
|
-
>
|
|
419
|
-
{dragging ? (
|
|
420
|
-
<div
|
|
421
|
-
aria-hidden
|
|
422
|
-
className={cn(
|
|
423
|
-
"pointer-events-none absolute inset-0 z-10 flex items-center justify-center",
|
|
424
|
-
"rounded-og-lg bg-og-surface-1/85 text-sm font-medium text-og-accent backdrop-blur-[1px]",
|
|
425
|
-
)}
|
|
426
|
-
>
|
|
427
|
-
<span className="inline-flex items-center gap-2">
|
|
428
|
-
<PaperclipIcon className="size-4" />
|
|
429
|
-
Drop files to attach
|
|
430
|
-
</span>
|
|
431
|
-
</div>
|
|
432
|
-
) : null}
|
|
433
|
-
{paused && effectiveControl ? (
|
|
434
|
-
<WorkstreamPausedStrip
|
|
435
|
-
control={effectiveControl}
|
|
436
|
-
queuedAheadCount={queuedAheadCount}
|
|
437
|
-
open={controlDetailsOpen}
|
|
438
|
-
busy={composer.resuming}
|
|
439
|
-
sending={composer.sending}
|
|
440
|
-
canControlWorkspace={canControlWorkspace}
|
|
441
|
-
controlLinks={controlLinks}
|
|
442
|
-
onOpenChange={setControlDetailsOpen}
|
|
443
|
-
onResume={() => void composer.resume()}
|
|
444
|
-
onResumeOption={(option) => void composer.resumeScope(option)}
|
|
445
|
-
/>
|
|
446
|
-
) : null}
|
|
447
|
-
{composer.restoredResources.length > 0 ? (
|
|
448
|
-
<RestoredResourceChips
|
|
449
|
-
resources={composer.restoredResources}
|
|
450
|
-
onRemove={composer.removeRestoredResource}
|
|
451
|
-
/>
|
|
452
|
-
) : null}
|
|
453
|
-
{attachments && attachments.attachments.length > 0 ? (
|
|
454
|
-
<AttachmentChips
|
|
455
|
-
attachments={attachments.attachments}
|
|
456
|
-
onRemove={attachments.remove}
|
|
457
|
-
onRetry={attachments.retry}
|
|
458
|
-
/>
|
|
459
|
-
) : null}
|
|
124
|
+
<Root controller={controller} className={className}>
|
|
125
|
+
<Frame>
|
|
126
|
+
<CommandPalette />
|
|
127
|
+
<Surface>
|
|
128
|
+
<PausedState />
|
|
129
|
+
<RestoredResources />
|
|
130
|
+
<Attachments />
|
|
460
131
|
{header}
|
|
461
|
-
<
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
value={composer.value}
|
|
465
|
-
onChange={(event) => composer.setValue(event.target.value)}
|
|
466
|
-
onKeyDown={onKeyDown}
|
|
467
|
-
onPaste={handlePaste}
|
|
468
|
-
placeholder={
|
|
469
|
-
paused && disabled !== true
|
|
470
|
-
? "Sending will resume this workstream…"
|
|
471
|
-
: (placeholder ?? "Message the agent…")
|
|
472
|
-
}
|
|
473
|
-
disabled={disabled}
|
|
474
|
-
autoFocus={autoFocus}
|
|
475
|
-
aria-label="Message the agent"
|
|
476
|
-
aria-keyshortcuts="Enter Meta+Enter Control+Enter Shift+Enter"
|
|
477
|
-
// A multiline textarea cannot legally take the combobox role or
|
|
478
|
-
// aria-expanded. It remains a native textbox and advertises the
|
|
479
|
-
// popup as list autocomplete; controls/active-descendant exist
|
|
480
|
-
// only while the suggestion list itself exists.
|
|
481
|
-
aria-autocomplete={paletteEnabled ? "list" : undefined}
|
|
482
|
-
aria-controls={paletteEnabled && palette.open ? listboxId : undefined}
|
|
483
|
-
aria-activedescendant={
|
|
484
|
-
paletteEnabled && palette.open
|
|
485
|
-
? `${listboxId}-option-${palette.highlight}`
|
|
486
|
-
: undefined
|
|
487
|
-
}
|
|
488
|
-
className={cn(
|
|
489
|
-
// Font size steps up to 16px below `md` so iOS never zooms the
|
|
490
|
-
// viewport on focus; desktop keeps the 15px og-md rhythm.
|
|
491
|
-
"block w-full resize-none bg-transparent px-4 pt-3.5 pb-1 text-base leading-6 md:text-og-md",
|
|
492
|
-
// The wrapper owns the whole-composer focus affordance (focus-within
|
|
493
|
-
// border + soft glow). Suppress any self-scoped focus outline on the
|
|
494
|
-
// textarea itself: `focus:outline-none` alone only sets outline-style
|
|
495
|
-
// on `:focus`, which a host app's zero-specificity
|
|
496
|
-
// `:where(...):focus-visible { outline: ... }` base rule re-applies as
|
|
497
|
-
// the full shorthand. `focus-visible:outline-none` matches the same
|
|
498
|
-
// state at class specificity and wins, so no second highlight (the
|
|
499
|
-
// top-half rectangle bounded to the textarea box) ever paints.
|
|
500
|
-
"text-og-fg placeholder:text-og-fg-subtle focus:outline-none focus-visible:outline-none",
|
|
501
|
-
"disabled:cursor-not-allowed disabled:opacity-60",
|
|
502
|
-
)}
|
|
503
|
-
/>
|
|
504
|
-
{confirmState && pendingDangerCommand ? (
|
|
505
|
-
<ConfirmBar
|
|
506
|
-
command={pendingDangerCommand}
|
|
507
|
-
onCancel={() => confirmState.resolve(false)}
|
|
508
|
-
onConfirm={() => confirmState.resolve(true)}
|
|
509
|
-
returnFocusRef={textareaRef}
|
|
510
|
-
/>
|
|
132
|
+
<Input placeholder={placeholder} autoFocus={autoFocus} />
|
|
133
|
+
{controller.confirmState ? (
|
|
134
|
+
<Confirmation />
|
|
511
135
|
) : (
|
|
512
|
-
<
|
|
513
|
-
{
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
|
518
|
-
{attachments ? (
|
|
519
|
-
<>
|
|
520
|
-
<input
|
|
521
|
-
ref={fileInputRef}
|
|
522
|
-
type="file"
|
|
523
|
-
multiple
|
|
524
|
-
className="hidden"
|
|
525
|
-
onChange={handleFileChange}
|
|
526
|
-
/>
|
|
527
|
-
<button
|
|
528
|
-
type="button"
|
|
529
|
-
disabled={disabled === true}
|
|
530
|
-
onClick={() => fileInputRef.current?.click()}
|
|
531
|
-
aria-label="Attach files"
|
|
532
|
-
title="Attach files"
|
|
533
|
-
className={cn(
|
|
534
|
-
"inline-flex size-8 items-center justify-center rounded-og-md",
|
|
535
|
-
"text-og-fg-muted transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
|
|
536
|
-
"disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-11",
|
|
537
|
-
)}
|
|
538
|
-
>
|
|
539
|
-
<PaperclipIcon className="size-4" />
|
|
540
|
-
</button>
|
|
541
|
-
</>
|
|
542
|
-
) : null}
|
|
136
|
+
<Footer>
|
|
137
|
+
{hasControls ? (
|
|
138
|
+
<Controls>
|
|
139
|
+
<AttachButton />
|
|
140
|
+
{transcription ? <ComposerTranscriptionControl {...transcription} /> : null}
|
|
543
141
|
{models ? (
|
|
544
|
-
<ModelPicker
|
|
545
|
-
models={models}
|
|
546
|
-
value={selectedModel}
|
|
547
|
-
onChange={(modelId) => onSelectModel?.(modelId)}
|
|
548
|
-
disabled={disabled === true}
|
|
549
|
-
/>
|
|
142
|
+
<ModelPicker models={models} value={selectedModel} onChange={onSelectModel} />
|
|
550
143
|
) : null}
|
|
551
144
|
{controlsStart}
|
|
552
|
-
</
|
|
145
|
+
</Controls>
|
|
553
146
|
) : (
|
|
554
|
-
<
|
|
555
|
-
{hint ?? "Enter to queue · Cmd/Ctrl+Enter to steer · Shift+Enter for a new line"}
|
|
556
|
-
</span>
|
|
147
|
+
<Hint>{hint}</Hint>
|
|
557
148
|
)}
|
|
558
|
-
<
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
disabled={composer.pausing || composer.resuming}
|
|
564
|
-
aria-label="Pause this workstream"
|
|
565
|
-
title="Pause this workstream; queued prompts and approvals are preserved"
|
|
566
|
-
className={cn(
|
|
567
|
-
"inline-flex size-8 items-center justify-center rounded-og-md border border-og-border pointer-coarse:size-11",
|
|
568
|
-
"bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
|
|
569
|
-
"hover:border-og-status-waiting/50 hover:text-og-status-waiting",
|
|
570
|
-
"disabled:opacity-50",
|
|
571
|
-
)}
|
|
572
|
-
>
|
|
573
|
-
{composer.pausing || composer.resuming ? (
|
|
574
|
-
<LoaderCircleIcon className="size-3.5 animate-og-spin" />
|
|
575
|
-
) : (
|
|
576
|
-
<PauseIcon className="size-3.5 fill-current" />
|
|
577
|
-
)}
|
|
578
|
-
</button>
|
|
579
|
-
) : null}
|
|
580
|
-
<button
|
|
581
|
-
type="button"
|
|
582
|
-
onClick={() => {
|
|
583
|
-
if (blockedByUpload) {
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
void composer.send();
|
|
587
|
-
}}
|
|
588
|
-
disabled={!canSend || disabled === true || commandDraftBlocked}
|
|
589
|
-
aria-label={paused ? "Send and resume" : "Send message"}
|
|
590
|
-
title={
|
|
591
|
-
paused
|
|
592
|
-
? "Send & resume (Enter); Steer & resume with Cmd/Ctrl+Enter"
|
|
593
|
-
: "Queue message (Enter); steer with Cmd/Ctrl+Enter"
|
|
594
|
-
}
|
|
595
|
-
className={cn(
|
|
596
|
-
"inline-flex size-8 items-center justify-center rounded-og-md pointer-coarse:size-11",
|
|
597
|
-
"bg-og-accent text-og-accent-fg shadow-og-sm",
|
|
598
|
-
"transition-[background-color,transform,opacity] duration-150 ease-og-spring",
|
|
599
|
-
"hover:bg-og-accent-strong active:scale-95",
|
|
600
|
-
"disabled:cursor-not-allowed disabled:bg-og-surface-3 disabled:text-og-fg-subtle disabled:shadow-none",
|
|
601
|
-
)}
|
|
602
|
-
>
|
|
603
|
-
{composer.sending ? (
|
|
604
|
-
<LoaderCircleIcon className="size-4 animate-og-spin" />
|
|
605
|
-
) : (
|
|
606
|
-
<ArrowUpIcon className="size-4" />
|
|
607
|
-
)}
|
|
608
|
-
</button>
|
|
609
|
-
</span>
|
|
610
|
-
</div>
|
|
149
|
+
<Actions>
|
|
150
|
+
<PauseButton />
|
|
151
|
+
<SendButton />
|
|
152
|
+
</Actions>
|
|
153
|
+
</Footer>
|
|
611
154
|
)}
|
|
612
|
-
</
|
|
613
|
-
</
|
|
614
|
-
<
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
<AnimatePresence>
|
|
618
|
-
{activeNotice ? (
|
|
619
|
-
<motion.p
|
|
620
|
-
initial={{ opacity: 0, height: 0 }}
|
|
621
|
-
animate={{ opacity: 1, height: "auto" }}
|
|
622
|
-
exit={{ opacity: 0, height: 0 }}
|
|
623
|
-
className={cn(
|
|
624
|
-
"overflow-hidden px-1 pt-1.5 text-xs",
|
|
625
|
-
activeNotice.tone === "ok" ? "text-og-fg-muted" : "text-og-status-failed",
|
|
626
|
-
)}
|
|
627
|
-
role={activeNotice.tone === "error" ? "alert" : "status"}
|
|
628
|
-
onAnimationComplete={() => {
|
|
629
|
-
if (activeNotice.tone === "ok") {
|
|
630
|
-
// Auto-dismiss success notices after a beat.
|
|
631
|
-
window.setTimeout(
|
|
632
|
-
() => setNotice((current) => (current === activeNotice ? null : current)),
|
|
633
|
-
2400,
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
}}
|
|
637
|
-
>
|
|
638
|
-
{activeNotice.message}
|
|
639
|
-
</motion.p>
|
|
640
|
-
) : null}
|
|
641
|
-
</AnimatePresence>
|
|
642
|
-
{composer.draftConflict ? (
|
|
643
|
-
<div
|
|
644
|
-
className="mt-1.5 flex flex-wrap items-center gap-2 px-1 text-og-xs text-og-status-failed"
|
|
645
|
-
role="alert"
|
|
646
|
-
>
|
|
647
|
-
<span className="min-w-0 flex-1">
|
|
648
|
-
This draft changed in another tab. Your local draft is still here.
|
|
649
|
-
</span>
|
|
650
|
-
<button
|
|
651
|
-
type="button"
|
|
652
|
-
className="underline underline-offset-2"
|
|
653
|
-
onClick={() => void composer.resolveDraftConflict("use_remote")}
|
|
654
|
-
>
|
|
655
|
-
Use other draft
|
|
656
|
-
</button>
|
|
657
|
-
<button
|
|
658
|
-
type="button"
|
|
659
|
-
className="font-medium underline underline-offset-2"
|
|
660
|
-
onClick={() => void composer.resolveDraftConflict("keep_mine")}
|
|
661
|
-
>
|
|
662
|
-
Keep mine
|
|
663
|
-
</button>
|
|
664
|
-
</div>
|
|
665
|
-
) : composer.draftSaving ? (
|
|
666
|
-
<p className="px-1 pt-1 text-og-xs text-og-fg-subtle" role="status">
|
|
667
|
-
Saving draft…
|
|
668
|
-
</p>
|
|
669
|
-
) : null}
|
|
670
|
-
</div>
|
|
671
|
-
);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function WorkstreamPausedStrip({
|
|
675
|
-
control,
|
|
676
|
-
queuedAheadCount,
|
|
677
|
-
open,
|
|
678
|
-
busy,
|
|
679
|
-
sending,
|
|
680
|
-
canControlWorkspace,
|
|
681
|
-
controlLinks,
|
|
682
|
-
onOpenChange,
|
|
683
|
-
onResume,
|
|
684
|
-
onResumeOption,
|
|
685
|
-
}: {
|
|
686
|
-
control: EffectiveSessionControl;
|
|
687
|
-
queuedAheadCount: number;
|
|
688
|
-
open: boolean;
|
|
689
|
-
busy: boolean;
|
|
690
|
-
sending: boolean;
|
|
691
|
-
canControlWorkspace: boolean;
|
|
692
|
-
controlLinks: ChatComposerProps["controlLinks"];
|
|
693
|
-
onOpenChange: (open: boolean) => void;
|
|
694
|
-
onResume: () => void;
|
|
695
|
-
onResumeOption: (option: EffectiveSessionControl["resumeOptions"][number]) => void;
|
|
696
|
-
}) {
|
|
697
|
-
const blocker = control.primaryBlocker;
|
|
698
|
-
const cause =
|
|
699
|
-
blocker?.kind === "workspace"
|
|
700
|
-
? "Workspace paused"
|
|
701
|
-
: control.directState === "paused"
|
|
702
|
-
? "Paused here"
|
|
703
|
-
: `Paused by ${blocker?.displayName ?? "parent"}`;
|
|
704
|
-
const primary = control.resumeOptions.find((option) => option.scope === "selected");
|
|
705
|
-
const broaderOptions = control.resumeOptions.filter(
|
|
706
|
-
(option) =>
|
|
707
|
-
option !== primary &&
|
|
708
|
-
option.scope !== "selected" &&
|
|
709
|
-
(option.scope !== "workspace" || canControlWorkspace),
|
|
710
|
-
);
|
|
711
|
-
return (
|
|
712
|
-
<div className="border-b border-og-status-waiting/25 bg-og-status-waiting/[0.07]">
|
|
713
|
-
<div className="flex min-w-0 items-center gap-2 px-3 py-2">
|
|
714
|
-
<PauseIcon className="size-3.5 shrink-0 text-og-status-waiting" />
|
|
715
|
-
<button
|
|
716
|
-
type="button"
|
|
717
|
-
className="min-w-0 flex-1 text-left"
|
|
718
|
-
onClick={() => onOpenChange(!open)}
|
|
719
|
-
aria-expanded={open}
|
|
720
|
-
>
|
|
721
|
-
<span className="block truncate text-og-xs font-medium text-og-fg">{cause}</span>
|
|
722
|
-
<span className="block truncate text-[11px] text-og-fg-muted">
|
|
723
|
-
{queuedAheadCount > 0
|
|
724
|
-
? `Send & resume queues behind ${queuedAheadCount} waiting prompt${queuedAheadCount === 1 ? "" : "s"}.`
|
|
725
|
-
: sending
|
|
726
|
-
? "Resuming and sending…"
|
|
727
|
-
: "Your next message resumes this workstream automatically."}
|
|
728
|
-
</span>
|
|
729
|
-
</button>
|
|
730
|
-
<button
|
|
731
|
-
type="button"
|
|
732
|
-
aria-label="Resume this workstream"
|
|
733
|
-
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"
|
|
734
|
-
disabled={busy}
|
|
735
|
-
onClick={onResume}
|
|
736
|
-
>
|
|
737
|
-
{busy ? (
|
|
738
|
-
<LoaderCircleIcon className="size-3.5 animate-og-spin" />
|
|
739
|
-
) : (
|
|
740
|
-
<PlayIcon className="size-3.5 fill-current" />
|
|
741
|
-
)}
|
|
742
|
-
<span className="hidden min-[400px]:inline">Resume this workstream</span>
|
|
743
|
-
<span className="min-[400px]:hidden">Resume</span>
|
|
744
|
-
</button>
|
|
745
|
-
<button
|
|
746
|
-
type="button"
|
|
747
|
-
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"
|
|
748
|
-
onClick={() => onOpenChange(!open)}
|
|
749
|
-
aria-label={open ? "Hide pause details" : "Show pause details"}
|
|
750
|
-
>
|
|
751
|
-
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
|
752
|
-
</button>
|
|
753
|
-
</div>
|
|
754
|
-
{open ? (
|
|
755
|
-
<div className="border-t border-og-status-waiting/20 px-3 pb-3 pt-2">
|
|
756
|
-
<ul className="grid gap-2" aria-label="Reasons this workstream is paused">
|
|
757
|
-
{control.blockers.map((entry, index) => (
|
|
758
|
-
<li
|
|
759
|
-
key={`${entry.kind}-${entry.sessionId ?? "workspace"}-${entry.revision}`}
|
|
760
|
-
className="text-og-xs"
|
|
761
|
-
>
|
|
762
|
-
<span className="font-medium text-og-fg">
|
|
763
|
-
{index === 0 ? "Paused by " : "Also paused by "}
|
|
764
|
-
</span>
|
|
765
|
-
{blockerHref(entry, controlLinks) ? (
|
|
766
|
-
<a
|
|
767
|
-
href={blockerHref(entry, controlLinks)}
|
|
768
|
-
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"
|
|
769
|
-
>
|
|
770
|
-
{entry.displayName}
|
|
771
|
-
</a>
|
|
772
|
-
) : (
|
|
773
|
-
<span className="font-medium text-og-fg">{entry.displayName}</span>
|
|
774
|
-
)}
|
|
775
|
-
{entry.reason ? <span className="text-og-fg-muted"> · {entry.reason}</span> : null}
|
|
776
|
-
{entry.actor ? <span className="text-og-fg-subtle"> · {entry.actor}</span> : null}
|
|
777
|
-
{entry.changedAt ? (
|
|
778
|
-
<span className="text-og-fg-subtle">
|
|
779
|
-
{" "}
|
|
780
|
-
· {formatRelativeTime(entry.changedAt)}
|
|
781
|
-
</span>
|
|
782
|
-
) : null}
|
|
783
|
-
</li>
|
|
784
|
-
))}
|
|
785
|
-
</ul>
|
|
786
|
-
{broaderOptions.length > 0 ? (
|
|
787
|
-
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
788
|
-
{broaderOptions.map((option) => (
|
|
789
|
-
<button
|
|
790
|
-
key={`${option.scope}-${option.targetId ?? "selected"}`}
|
|
791
|
-
type="button"
|
|
792
|
-
disabled={busy}
|
|
793
|
-
title={option.impactCopy}
|
|
794
|
-
onClick={() => onResumeOption(option)}
|
|
795
|
-
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"
|
|
796
|
-
>
|
|
797
|
-
<span className="block font-medium">
|
|
798
|
-
{option.scope === "workspace" ? "Resume workspace" : "Resume from this session"}
|
|
799
|
-
</span>
|
|
800
|
-
<span className="block text-[10px] text-og-fg-subtle">
|
|
801
|
-
{option.selectedStateAfter === "active"
|
|
802
|
-
? "This session will be able to run"
|
|
803
|
-
: `Still paused by ${option.remainingPrimaryBlocker?.displayName ?? "a narrower pause"}`}
|
|
804
|
-
</span>
|
|
805
|
-
</button>
|
|
806
|
-
))}
|
|
807
|
-
</div>
|
|
808
|
-
) : null}
|
|
809
|
-
</div>
|
|
810
|
-
) : null}
|
|
811
|
-
</div>
|
|
812
|
-
);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
function blockerHref(
|
|
816
|
-
blocker: EffectiveSessionControl["blockers"][number],
|
|
817
|
-
links: ChatComposerProps["controlLinks"],
|
|
818
|
-
): string | undefined {
|
|
819
|
-
if (blocker.kind === "workspace") return links?.workspaceHref;
|
|
820
|
-
return blocker.sessionId ? links?.sessionHref?.(blocker.sessionId) : undefined;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
function RestoredResourceChips({
|
|
824
|
-
resources,
|
|
825
|
-
onRemove,
|
|
826
|
-
}: {
|
|
827
|
-
resources: ComposerState["restoredResources"];
|
|
828
|
-
onRemove: (index: number) => void;
|
|
829
|
-
}) {
|
|
830
|
-
return (
|
|
831
|
-
<div
|
|
832
|
-
className="flex flex-wrap gap-1.5 border-b border-og-border px-3 py-2"
|
|
833
|
-
aria-label="Restored prompt resources"
|
|
834
|
-
>
|
|
835
|
-
{resources.map((resource, index) => (
|
|
836
|
-
<span
|
|
837
|
-
key={`${resource.kind}-${resource.kind === "file" ? resource.fileId : resource.uri}`}
|
|
838
|
-
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"
|
|
839
|
-
>
|
|
840
|
-
<FileIcon className="size-3 shrink-0" />
|
|
841
|
-
<span className="truncate">
|
|
842
|
-
{resource.kind === "file" ? `File ${resource.fileId.slice(0, 8)}` : resource.uri}
|
|
843
|
-
</span>
|
|
844
|
-
<button
|
|
845
|
-
type="button"
|
|
846
|
-
className="shrink-0 hover:text-og-fg"
|
|
847
|
-
onClick={() => onRemove(index)}
|
|
848
|
-
aria-label={`Remove restored resource ${index + 1}`}
|
|
849
|
-
>
|
|
850
|
-
<XIcon className="size-3" />
|
|
851
|
-
</button>
|
|
852
|
-
</span>
|
|
853
|
-
))}
|
|
854
|
-
</div>
|
|
855
|
-
);
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
/**
|
|
859
|
-
* The danger confirm bar — reuses og-status-failed tokens (like the stop
|
|
860
|
-
* control). Inline (not a modal) but keyboard-complete: Escape cancels, focus
|
|
861
|
-
* moves to the primary action when it appears and returns to the composer on
|
|
862
|
-
* dismiss, and the primary button names the action rather than a bare "Confirm".
|
|
863
|
-
*/
|
|
864
|
-
function ConfirmBar({
|
|
865
|
-
command,
|
|
866
|
-
onCancel,
|
|
867
|
-
onConfirm,
|
|
868
|
-
returnFocusRef,
|
|
869
|
-
}: {
|
|
870
|
-
command: SlashCommand;
|
|
871
|
-
onCancel: () => void;
|
|
872
|
-
onConfirm: () => void;
|
|
873
|
-
/** Focus returns here when the bar dismisses (the composer textarea). */
|
|
874
|
-
returnFocusRef?: RefObject<HTMLTextAreaElement | null> | undefined;
|
|
875
|
-
}) {
|
|
876
|
-
const confirmRef = useRef<HTMLButtonElement | null>(null);
|
|
877
|
-
const descriptionId = useId();
|
|
878
|
-
useEffect(() => {
|
|
879
|
-
const returnTo = returnFocusRef?.current ?? null;
|
|
880
|
-
confirmRef.current?.focus();
|
|
881
|
-
return () => {
|
|
882
|
-
returnTo?.focus();
|
|
883
|
-
};
|
|
884
|
-
}, [returnFocusRef]);
|
|
885
|
-
return (
|
|
886
|
-
<div
|
|
887
|
-
role="alertdialog"
|
|
888
|
-
aria-label={`Confirm /${command.name}`}
|
|
889
|
-
aria-describedby={descriptionId}
|
|
890
|
-
data-testid="danger-confirm"
|
|
891
|
-
onKeyDown={(event) => {
|
|
892
|
-
if (event.key === "Escape") {
|
|
893
|
-
event.preventDefault();
|
|
894
|
-
event.stopPropagation();
|
|
895
|
-
onCancel();
|
|
896
|
-
}
|
|
897
|
-
}}
|
|
898
|
-
className="flex items-end justify-between gap-2 px-2.5 pb-2.5 pt-1"
|
|
899
|
-
>
|
|
900
|
-
<span id={descriptionId} className="min-w-0 flex-1 px-1.5 text-og-sm text-og-status-failed">
|
|
901
|
-
Run <span className="font-mono">/{command.name}</span>? {command.description}
|
|
902
|
-
</span>
|
|
903
|
-
<span className="flex shrink-0 items-center gap-1.5">
|
|
904
|
-
<button
|
|
905
|
-
type="button"
|
|
906
|
-
onClick={onCancel}
|
|
907
|
-
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-10"
|
|
908
|
-
>
|
|
909
|
-
Cancel
|
|
910
|
-
</button>
|
|
911
|
-
<button
|
|
912
|
-
ref={confirmRef}
|
|
913
|
-
type="button"
|
|
914
|
-
onClick={onConfirm}
|
|
915
|
-
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-10"
|
|
916
|
-
>
|
|
917
|
-
Run /{command.name}
|
|
918
|
-
</button>
|
|
919
|
-
</span>
|
|
920
|
-
</div>
|
|
921
|
-
);
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* The attachment-chips strip rendered above the textarea while files are
|
|
926
|
-
* attached. Each chip shows an image preview (or a type icon), the filename,
|
|
927
|
-
* an upload/size/failed status line, and a remove control. A failed upload
|
|
928
|
-
* surfaces its actual error (inline, full text on hover via `title`) and offers
|
|
929
|
-
* a retry alongside remove — a failed attachment never blocks send (only an
|
|
930
|
-
* in-progress upload does), and removing it clears the way. Styled with the
|
|
931
|
-
* package's og-* tokens so it themes in any consumer.
|
|
932
|
-
*/
|
|
933
|
-
function AttachmentChips({
|
|
934
|
-
attachments,
|
|
935
|
-
onRemove,
|
|
936
|
-
onRetry,
|
|
937
|
-
}: {
|
|
938
|
-
attachments: UseFileAttachmentsResult["attachments"];
|
|
939
|
-
onRemove: (id: string) => void;
|
|
940
|
-
onRetry?: ((id: string) => void) | undefined;
|
|
941
|
-
}) {
|
|
942
|
-
return (
|
|
943
|
-
<div className="flex flex-wrap gap-2 border-b border-og-border px-3 py-2">
|
|
944
|
-
{attachments.map((attachment) => {
|
|
945
|
-
const failed = attachment.status === "failed";
|
|
946
|
-
const statusText =
|
|
947
|
-
attachment.status === "uploading"
|
|
948
|
-
? "Uploading"
|
|
949
|
-
: failed
|
|
950
|
-
? attachment.error || "Upload failed"
|
|
951
|
-
: formatBytes(attachment.sizeBytes);
|
|
952
|
-
return (
|
|
953
|
-
<div
|
|
954
|
-
key={attachment.id}
|
|
955
|
-
className={cn(
|
|
956
|
-
"flex min-w-0 max-w-[240px] items-center gap-2 rounded-og-md border px-2 py-1.5 text-og-sm",
|
|
957
|
-
failed
|
|
958
|
-
? "border-og-status-failed/40 bg-og-status-failed/10"
|
|
959
|
-
: "border-og-border bg-og-surface-2",
|
|
960
|
-
)}
|
|
961
|
-
>
|
|
962
|
-
{attachment.previewUrl ? (
|
|
963
|
-
<img
|
|
964
|
-
src={attachment.previewUrl}
|
|
965
|
-
alt=""
|
|
966
|
-
className="size-8 shrink-0 rounded object-cover"
|
|
967
|
-
/>
|
|
968
|
-
) : attachment.contentType.startsWith("image/") ? (
|
|
969
|
-
<ImageIcon className="size-4 shrink-0 text-og-fg-muted" />
|
|
970
|
-
) : (
|
|
971
|
-
<FileIcon className="size-4 shrink-0 text-og-fg-muted" />
|
|
972
|
-
)}
|
|
973
|
-
<div className="min-w-0 flex-1">
|
|
974
|
-
<div className="truncate font-medium text-og-fg">{attachment.name}</div>
|
|
975
|
-
<div
|
|
976
|
-
className={cn(
|
|
977
|
-
"truncate text-og-xs",
|
|
978
|
-
failed ? "text-og-status-failed" : "text-og-fg-subtle",
|
|
979
|
-
)}
|
|
980
|
-
title={failed ? statusText : undefined}
|
|
981
|
-
>
|
|
982
|
-
{statusText}
|
|
983
|
-
</div>
|
|
984
|
-
</div>
|
|
985
|
-
{attachment.status === "uploading" ? (
|
|
986
|
-
<LoaderCircleIcon className="size-3.5 shrink-0 animate-og-spin" />
|
|
987
|
-
) : null}
|
|
988
|
-
{failed && onRetry ? (
|
|
989
|
-
<button
|
|
990
|
-
type="button"
|
|
991
|
-
onClick={() => onRetry(attachment.id)}
|
|
992
|
-
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"
|
|
993
|
-
aria-label={`Retry ${attachment.name}`}
|
|
994
|
-
title="Retry upload"
|
|
995
|
-
>
|
|
996
|
-
<RotateCwIcon className="size-3.5" />
|
|
997
|
-
</button>
|
|
998
|
-
) : null}
|
|
999
|
-
<button
|
|
1000
|
-
type="button"
|
|
1001
|
-
onClick={() => onRemove(attachment.id)}
|
|
1002
|
-
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"
|
|
1003
|
-
aria-label={`Remove ${attachment.name}`}
|
|
1004
|
-
>
|
|
1005
|
-
<XIcon className="size-3.5" />
|
|
1006
|
-
</button>
|
|
1007
|
-
</div>
|
|
1008
|
-
);
|
|
1009
|
-
})}
|
|
1010
|
-
</div>
|
|
1011
|
-
);
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
/** The in-composer /help panel, rendered entirely from the registry. */
|
|
1015
|
-
function HelpPanel({
|
|
1016
|
-
commands,
|
|
1017
|
-
onClose,
|
|
1018
|
-
}: {
|
|
1019
|
-
commands: readonly SlashCommand[];
|
|
1020
|
-
onClose: () => void;
|
|
1021
|
-
}) {
|
|
1022
|
-
return (
|
|
1023
|
-
<motion.div
|
|
1024
|
-
initial={{ opacity: 0, height: 0 }}
|
|
1025
|
-
animate={{ opacity: 1, height: "auto" }}
|
|
1026
|
-
exit={{ opacity: 0, height: 0 }}
|
|
1027
|
-
className="mt-2 overflow-hidden rounded-og-lg border border-og-border bg-og-surface-2"
|
|
1028
|
-
>
|
|
1029
|
-
<div className="flex items-center justify-between border-b border-og-border px-3 py-1.5">
|
|
1030
|
-
<span className="text-og-sm font-medium text-og-fg">Commands</span>
|
|
1031
|
-
<button
|
|
1032
|
-
type="button"
|
|
1033
|
-
onClick={onClose}
|
|
1034
|
-
className="text-og-xs text-og-fg-subtle hover:text-og-fg"
|
|
1035
|
-
>
|
|
1036
|
-
Close
|
|
1037
|
-
</button>
|
|
1038
|
-
</div>
|
|
1039
|
-
<ul className="py-1">
|
|
1040
|
-
{commands.map((command) => {
|
|
1041
|
-
const hint = argHint(command.args);
|
|
1042
|
-
return (
|
|
1043
|
-
<li key={command.name} className="flex items-baseline gap-2 px-3 py-1">
|
|
1044
|
-
<span className="font-mono text-og-sm text-og-accent">
|
|
1045
|
-
/{command.name}
|
|
1046
|
-
{hint ? <span className="ml-1 text-og-fg-subtle">{hint}</span> : null}
|
|
1047
|
-
</span>
|
|
1048
|
-
<span className="text-og-sm text-og-fg-muted">{command.description}</span>
|
|
1049
|
-
{command.danger ? (
|
|
1050
|
-
<span className="ml-auto rounded-og-xs bg-og-status-failed/15 px-1 text-og-xs uppercase tracking-wide text-og-status-failed">
|
|
1051
|
-
danger
|
|
1052
|
-
</span>
|
|
1053
|
-
) : null}
|
|
1054
|
-
</li>
|
|
1055
|
-
);
|
|
1056
|
-
})}
|
|
1057
|
-
</ul>
|
|
1058
|
-
</motion.div>
|
|
155
|
+
</Surface>
|
|
156
|
+
</Frame>
|
|
157
|
+
<Help />
|
|
158
|
+
<Status />
|
|
159
|
+
</Root>
|
|
1059
160
|
);
|
|
1060
161
|
}
|