@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.93
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/CHANGELOG.md +400 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +715 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +7923 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +7973 -0
- package/dist/node/index.d.cts +715 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +715 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +7923 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +124 -0
- package/package.json +99 -3
- package/src/client/runtime-host.ts +84 -0
- package/src/client/ws-session-client.ts +131 -0
- package/src/clipboard.ts +81 -0
- package/src/components/AgentActivityPanel.tsx +371 -0
- package/src/components/AgentSwitcherSheet.tsx +93 -0
- package/src/components/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +772 -0
- package/src/components/Dialog.tsx +230 -0
- package/src/components/DiffLines.tsx +81 -0
- package/src/components/ExecutionDetailSheet.tsx +142 -0
- package/src/components/HelpSheet.tsx +104 -0
- package/src/components/PermissionPrompt.tsx +661 -0
- package/src/components/PersonalUsageDashboard.tsx +39 -0
- package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
- package/src/components/PersonalUsageDashboardSections.tsx +250 -0
- package/src/components/PopupMenu.tsx +166 -0
- package/src/components/ProjectPanel.tsx +268 -0
- package/src/components/SessionMonitor.tsx +180 -0
- package/src/components/SessionSidebar.tsx +429 -0
- package/src/components/SessionSurface.tsx +455 -0
- package/src/components/SessionSurfaceChrome.tsx +316 -0
- package/src/components/SettingsFields.tsx +159 -0
- package/src/components/SettingsGeneralSection.tsx +49 -0
- package/src/components/SettingsMcpSection.tsx +157 -0
- package/src/components/SettingsPermissionsSection.tsx +137 -0
- package/src/components/SettingsPluginsSection.tsx +165 -0
- package/src/components/SettingsProvidersSection.tsx +211 -0
- package/src/components/SettingsScreen.tsx +340 -0
- package/src/components/StatusControls.tsx +294 -0
- package/src/components/composer-attachments.ts +246 -0
- package/src/components/personal-usage-dashboard-types.ts +26 -0
- package/src/driver-labels.ts +88 -0
- package/src/execution-entry-status.ts +55 -0
- package/src/goal-view.ts +19 -0
- package/src/hooks/command-menu.ts +65 -0
- package/src/hooks/excluded-commands.ts +52 -0
- package/src/hooks/prompt-state.ts +83 -0
- package/src/hooks/server-message-handling.ts +92 -0
- package/src/hooks/session-client-types.ts +329 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -0
- package/src/hooks/use-execution-detail.ts +118 -0
- package/src/hooks/use-media-query.ts +27 -0
- package/src/hooks/use-model-list.ts +45 -0
- package/src/hooks/use-personal-usage.ts +138 -0
- package/src/hooks/use-project-panel.ts +117 -0
- package/src/hooks/use-schedules-state.ts +92 -0
- package/src/hooks/use-session-directory.ts +202 -0
- package/src/hooks/use-settings-state.ts +115 -0
- package/src/hooks/useSessionClient.ts +991 -0
- package/src/index.ts +66 -0
- package/src/product-identity.tsx +26 -0
- package/src/schedule-view.ts +92 -0
- package/src/styles/surface.css +231 -0
- package/src/styles/surface.css.d.mts +1 -0
- package/src/styles/theme.css +38 -0
- package/src/styles/theme.css.d.mts +1 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { useProductIdentity } from '../product-identity.js';
|
|
2
|
+
import { ArrowUp, Paperclip, Square, Target, X } from 'lucide-react';
|
|
3
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { commandMenuFor } from '../hooks/command-menu.js';
|
|
6
|
+
import type { ICommandMenuItem } from '../hooks/command-menu.js';
|
|
7
|
+
import {
|
|
8
|
+
buildPromptWithAttachments,
|
|
9
|
+
evaluateCandidateFile,
|
|
10
|
+
type ICandidateFile,
|
|
11
|
+
type IDraftAttachment,
|
|
12
|
+
type IPickedFile,
|
|
13
|
+
} from './composer-attachments.js';
|
|
14
|
+
import { StatusRow } from './StatusControls.js';
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
IQueuedPrompt,
|
|
18
|
+
TCommandCatalog,
|
|
19
|
+
TModelListSnapshot,
|
|
20
|
+
TSessionStatus,
|
|
21
|
+
} from '../hooks/session-client-types.js';
|
|
22
|
+
|
|
23
|
+
export type { IPickedFile } from './composer-attachments.js';
|
|
24
|
+
|
|
25
|
+
/** What a caller can do to the composer from outside it — currently just reclaiming focus. */
|
|
26
|
+
export interface IComposerHandle {
|
|
27
|
+
/** Focuses the message field — used to send focus back there once a docked prompt is answered. */
|
|
28
|
+
focus: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The draft as persisted: the typed text plus any attachment chips (#3282 §4d). */
|
|
32
|
+
interface IStoredDraft {
|
|
33
|
+
readonly text: string;
|
|
34
|
+
readonly attachments: readonly IDraftAttachment[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EMPTY_DRAFT: IStoredDraft = { text: '', attachments: [] };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* #3282 §4e: a command whose whole job is opening a GUI screen — choosing it from the `/` menu runs
|
|
41
|
+
* it at once (its `ui_intent` opens the screen the normal way) instead of filling the draft and
|
|
42
|
+
* waiting for Enter, since there is nothing useful to type after it. `help` never reaches the
|
|
43
|
+
* session at all (`useSessionClient.ts`'s `send` opens the Help sheet locally). Every other command
|
|
44
|
+
* still "runs as today": chosen or Tab-completed, it fills the draft for its arguments.
|
|
45
|
+
*/
|
|
46
|
+
const IMMEDIATE_SCREEN_COMMANDS: ReadonlySet<string> = new Set(['settings', 'resume', 'help']);
|
|
47
|
+
|
|
48
|
+
function isImmediateScreenCommand(item: ICommandMenuItem): boolean {
|
|
49
|
+
return item.kind === 'command' && IMMEDIATE_SCREEN_COMMANDS.has(item.name);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The group a menu row falls under — shown as a header above the first row of each. */
|
|
53
|
+
function menuGroupLabel(item: ICommandMenuItem): 'Commands' | 'Skills' {
|
|
54
|
+
return item.kind === 'skill' ? 'Skills' : 'Commands';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* #3280 §4: the unsent draft survives a Chat → Usage → Chat switch (the composer unmounts), a page
|
|
59
|
+
* reload, and a desktop relaunch — `localStorage`, not `sessionStorage`, since only `localStorage`
|
|
60
|
+
* survives a closed window/tab being reopened. Namespaced (the configured draft prefix, matching
|
|
61
|
+
* the configured restore key in `use-session-directory.ts`) so a page hosting other state under the
|
|
62
|
+
* same origin does not collide. Per session id when one is known; a single fallback key before the
|
|
63
|
+
* first status arrives (the gap is brief and is reconciled once it does — see the effect below).
|
|
64
|
+
*
|
|
65
|
+
* #3282 §4d: the stored value is now JSON (`IStoredDraft`), not the bare text string it used to be —
|
|
66
|
+
* `parseStoredDraft` treats anything that does not parse as that shape (including a draft saved
|
|
67
|
+
* before this change shipped) as plain text with no attachments, so an old stored draft still loads.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
function draftStorageKey(namespace: string, sessionId: string | undefined): string {
|
|
71
|
+
return sessionId ? `${namespace}.draft.${sessionId}` : `${namespace}.draft`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A stored attachment's shape is never trusted blindly: it is `localStorage`, not this component's
|
|
76
|
+
* own state, so it can be edited by hand, left over from a future version with a different shape, or
|
|
77
|
+
* just corrupted. A missing/non-string `name` or `relativePath` would otherwise show a blank chip and
|
|
78
|
+
* send a broken (or empty) `@`-reference on submit — dropped instead of risking either.
|
|
79
|
+
*/
|
|
80
|
+
function isStoredAttachment(value: unknown): value is IDraftAttachment {
|
|
81
|
+
if (!value || typeof value !== 'object') return false;
|
|
82
|
+
const candidate = value as Record<string, unknown>;
|
|
83
|
+
return (
|
|
84
|
+
typeof candidate.id === 'string' &&
|
|
85
|
+
typeof candidate.name === 'string' &&
|
|
86
|
+
typeof candidate.relativePath === 'string' &&
|
|
87
|
+
typeof candidate.size === 'number'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseStoredDraft(raw: string | null): IStoredDraft {
|
|
92
|
+
if (!raw) return EMPTY_DRAFT;
|
|
93
|
+
try {
|
|
94
|
+
const parsed: unknown = JSON.parse(raw);
|
|
95
|
+
if (parsed && typeof parsed === 'object' && typeof (parsed as { text?: unknown }).text === 'string') {
|
|
96
|
+
const attachments = (parsed as { attachments?: unknown }).attachments;
|
|
97
|
+
return {
|
|
98
|
+
text: (parsed as { text: string }).text,
|
|
99
|
+
attachments: Array.isArray(attachments) ? attachments.filter(isStoredAttachment) : [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Not JSON — a draft saved before attachments shipped. Fall through to plain text below.
|
|
104
|
+
}
|
|
105
|
+
return { text: raw, attachments: [] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Best-effort: a private window, cleared site data, or a full quota still leaves typing working. */
|
|
109
|
+
function readDraft(namespace: string, sessionId: string | undefined): IStoredDraft {
|
|
110
|
+
try {
|
|
111
|
+
return parseStoredDraft(window.localStorage.getItem(draftStorageKey(namespace, sessionId)));
|
|
112
|
+
} catch {
|
|
113
|
+
// allow-fallback: storage unavailable — the draft still lives in component state this session.
|
|
114
|
+
return EMPTY_DRAFT;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeDraft(namespace: string, sessionId: string | undefined, value: IStoredDraft): void {
|
|
119
|
+
try {
|
|
120
|
+
const key = draftStorageKey(namespace, sessionId);
|
|
121
|
+
if (!value.text && value.attachments.length === 0) window.localStorage.removeItem(key);
|
|
122
|
+
else window.localStorage.setItem(key, JSON.stringify(value));
|
|
123
|
+
} catch {
|
|
124
|
+
// allow-fallback: same as readDraft — typing (and sending) still work without persistence.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The composer — the control centre, as desktop agent apps place it: the message box, a `/` menu of
|
|
130
|
+
* the commands and skills the session offers, and a status row (model, permission mode, effort,
|
|
131
|
+
* context). The row's controls run the session's own commands (`/provider`, `/mode`, `/effort`), so a
|
|
132
|
+
* setting changes through the one path every client shares.
|
|
133
|
+
*/
|
|
134
|
+
export const Composer = forwardRef<
|
|
135
|
+
IComposerHandle,
|
|
136
|
+
{
|
|
137
|
+
onSubmit: (prompt: string) => void;
|
|
138
|
+
onCommand: (name: string, args?: string) => void;
|
|
139
|
+
catalog: TCommandCatalog | null;
|
|
140
|
+
status: TSessionStatus | null;
|
|
141
|
+
/**
|
|
142
|
+
* False while the transport is not `connected` (issue #3280 §5): Enter and Send refuse to submit,
|
|
143
|
+
* and nothing typed is cleared or lost — the composer never sends into a socket that is not there.
|
|
144
|
+
* #3282 §2 (part 2): the status chips and the `/` command menu are disabled the same way.
|
|
145
|
+
*/
|
|
146
|
+
connected?: boolean;
|
|
147
|
+
/** #3280 §2: a turn (or a blocking command) is running — Send becomes Stop and Esc stops it too. */
|
|
148
|
+
running: boolean;
|
|
149
|
+
onStop: () => void;
|
|
150
|
+
/** #3280 §2: the prompt queued behind the running turn, or null when none is queued. */
|
|
151
|
+
queued: IQueuedPrompt | null;
|
|
152
|
+
onCancelQueue: () => void;
|
|
153
|
+
/**
|
|
154
|
+
* Opens the host's native multi-file dialog with real filesystem paths — present only on the
|
|
155
|
+
* desktop app (#3282 §4d). Its absence means the attach button falls back to a plain HTML file
|
|
156
|
+
* picker, whose picks a browser can never resolve to a path (rule 3: shown plainly, nothing
|
|
157
|
+
* attached).
|
|
158
|
+
*/
|
|
159
|
+
pickFiles?: () => Promise<readonly IPickedFile[]>;
|
|
160
|
+
/**
|
|
161
|
+
* Resolves a dropped or picked `File` to its real filesystem path — present only on the desktop
|
|
162
|
+
* app, via Electron's `webUtils.getPathForFile` (#3282 §4d). Its absence means a drop can never
|
|
163
|
+
* become an `@`-reference either.
|
|
164
|
+
*/
|
|
165
|
+
getPathForFile?: (file: File) => string;
|
|
166
|
+
/** #3282 §2 (part 2): the model control's pop-up menu — null until requested. */
|
|
167
|
+
modelList?: TModelListSnapshot | null;
|
|
168
|
+
onRequestModelList?: () => void;
|
|
169
|
+
/** Applies a model/mode/effort choice without a conversation card (the control's label confirms it). */
|
|
170
|
+
onSilentCommand?: (name: string, args?: string) => void;
|
|
171
|
+
/**
|
|
172
|
+
* #3282 §4b: "Manage providers…" opens Settings at the Providers & Models section. Defaults to
|
|
173
|
+
* dispatching the bare `/provider` command (the pre-#3282-§4b behavior) for a caller that has not
|
|
174
|
+
* wired Settings — every shipped caller passes its own `state.openSettings('providers')`.
|
|
175
|
+
*/
|
|
176
|
+
onManageProviders?: () => void;
|
|
177
|
+
}
|
|
178
|
+
>(function Composer(
|
|
179
|
+
{
|
|
180
|
+
onSubmit,
|
|
181
|
+
onCommand,
|
|
182
|
+
catalog,
|
|
183
|
+
status,
|
|
184
|
+
connected = true,
|
|
185
|
+
running,
|
|
186
|
+
onStop,
|
|
187
|
+
queued,
|
|
188
|
+
onCancelQueue,
|
|
189
|
+
pickFiles,
|
|
190
|
+
getPathForFile,
|
|
191
|
+
modelList = null,
|
|
192
|
+
onRequestModelList = () => {},
|
|
193
|
+
onSilentCommand = onCommand,
|
|
194
|
+
onManageProviders = () => onCommand('provider'),
|
|
195
|
+
},
|
|
196
|
+
ref,
|
|
197
|
+
): React.ReactElement {
|
|
198
|
+
const { identity, storage: { browserNamespace } } = useProductIdentity();
|
|
199
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
200
|
+
useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus() }), []);
|
|
201
|
+
const sessionId = status?.sessionId;
|
|
202
|
+
const sessionIdRef = useRef(sessionId);
|
|
203
|
+
// Whether a REAL session id has ever been seen. A switch goes A -> null -> B — `session_switched`
|
|
204
|
+
// clears `sessionStatus` before `get-status` answers (`useSessionClient.ts`) — so `sessionId` turns
|
|
205
|
+
// transiently `undefined` on an ordinary switch too, not only before the very first status. This
|
|
206
|
+
// ref is the one thing that distinguishes "no session has ever been known yet" (the fallback-key
|
|
207
|
+
// migration case) from "between two known sessions right now" — never overload `undefined` for it.
|
|
208
|
+
const hasKnownSessionRef = useRef(sessionId !== undefined);
|
|
209
|
+
// The draft (text + attachments) as last set, read inside effects/handlers without depending on
|
|
210
|
+
// component state and risking a stale closure — kept in lockstep with the `draft`/`attachments`
|
|
211
|
+
// state below by `setDraft`/`setAttachments`, the only two places that mutate it.
|
|
212
|
+
const stateRef = useRef<IStoredDraft>(EMPTY_DRAFT);
|
|
213
|
+
const [draft, setDraftState] = useState(() => {
|
|
214
|
+
const initial = readDraft(browserNamespace, sessionId);
|
|
215
|
+
stateRef.current = initial;
|
|
216
|
+
return initial.text;
|
|
217
|
+
});
|
|
218
|
+
const [attachments, setAttachmentsState] = useState<readonly IDraftAttachment[]>(
|
|
219
|
+
() => stateRef.current.attachments,
|
|
220
|
+
);
|
|
221
|
+
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
|
|
222
|
+
const [selected, setSelected] = useState(0);
|
|
223
|
+
const [dismissed, setDismissed] = useState(false);
|
|
224
|
+
// #3282 §2 (part 2): the slash menu is disabled while disconnected — none of its commands could run.
|
|
225
|
+
const menu = dismissed || !connected ? null : commandMenuFor(catalog, draft);
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
setSelected(0);
|
|
228
|
+
setDismissed(false);
|
|
229
|
+
}, [draft]);
|
|
230
|
+
// #3282 §4e: the menu can hold more rows than fit in its scroll area — keyboard navigation (below)
|
|
231
|
+
// must keep the highlighted row in view, not just move the highlight off-screen.
|
|
232
|
+
const menuOptionRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
menuOptionRefs.current[selected]?.scrollIntoView?.({ block: 'nearest' });
|
|
235
|
+
}, [selected, menu]);
|
|
236
|
+
/** #3280 §4: every draft change is persisted at once, so a reload or relaunch loses nothing. */
|
|
237
|
+
const setDraft = (value: string): void => {
|
|
238
|
+
stateRef.current = { ...stateRef.current, text: value };
|
|
239
|
+
setDraftState(value);
|
|
240
|
+
writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
|
|
241
|
+
};
|
|
242
|
+
/** #3282 §4d: attachment chips persist alongside the text, under the same per-session draft key. */
|
|
243
|
+
const setAttachments = (
|
|
244
|
+
updater: readonly IDraftAttachment[] | ((current: readonly IDraftAttachment[]) => readonly IDraftAttachment[]),
|
|
245
|
+
): void => {
|
|
246
|
+
const next = typeof updater === 'function' ? updater(stateRef.current.attachments) : updater;
|
|
247
|
+
stateRef.current = { ...stateRef.current, attachments: next };
|
|
248
|
+
setAttachmentsState(next);
|
|
249
|
+
writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
|
|
250
|
+
};
|
|
251
|
+
// #3280 §4: a session switch shows THAT session's own saved draft, never what was typed for
|
|
252
|
+
// another one. The session id becoming known for the very FIRST time (the fallback key was in use
|
|
253
|
+
// until now) instead carries over what is already typed, rather than discarding it. A transient
|
|
254
|
+
// `undefined` mid-switch, once a real id has already been seen, is not a change at all: stay bound
|
|
255
|
+
// to the last known session — keep showing and writing to ITS draft — until a new CONCRETE id
|
|
256
|
+
// arrives, so keystrokes typed during the round trip never land under the wrong session (or the
|
|
257
|
+
// fallback key).
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
if (sessionId === undefined && hasKnownSessionRef.current) return;
|
|
260
|
+
if (sessionIdRef.current === sessionId) return;
|
|
261
|
+
const firstArrival = !hasKnownSessionRef.current;
|
|
262
|
+
const previous = sessionIdRef.current;
|
|
263
|
+
sessionIdRef.current = sessionId;
|
|
264
|
+
if (sessionId !== undefined) hasKnownSessionRef.current = true;
|
|
265
|
+
const stored = readDraft(browserNamespace, sessionId);
|
|
266
|
+
const hasStored = stored.text !== '' || stored.attachments.length > 0;
|
|
267
|
+
const hasCarryOver = stateRef.current.text !== '' || stateRef.current.attachments.length > 0;
|
|
268
|
+
if (firstArrival && !hasStored && hasCarryOver) {
|
|
269
|
+
writeDraft(browserNamespace, sessionId, stateRef.current);
|
|
270
|
+
writeDraft(browserNamespace, previous, EMPTY_DRAFT);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
stateRef.current = stored;
|
|
274
|
+
setDraftState(stored.text);
|
|
275
|
+
setAttachmentsState(stored.attachments);
|
|
276
|
+
setAttachmentNotice(null); // a notice belongs to the attempt just made in the session left behind
|
|
277
|
+
}, [browserNamespace, sessionId]);
|
|
278
|
+
|
|
279
|
+
const submit = (): void => {
|
|
280
|
+
if (!connected) return;
|
|
281
|
+
const text = draft.trim();
|
|
282
|
+
if (!text && attachments.length === 0) return;
|
|
283
|
+
onSubmit(buildPromptWithAttachments(text, attachments));
|
|
284
|
+
setDraft('');
|
|
285
|
+
setAttachments([]);
|
|
286
|
+
setAttachmentNotice(null);
|
|
287
|
+
};
|
|
288
|
+
const workspacePath = status?.workspace?.path;
|
|
289
|
+
const totalAttachedBytes = attachments.reduce((sum, a) => sum + a.size, 0);
|
|
290
|
+
/** Evaluate every dropped/picked file in order, so a mixed batch attaches what it can. */
|
|
291
|
+
const addCandidates = (candidates: readonly ICandidateFile[]): void => {
|
|
292
|
+
if (candidates.length === 0) return;
|
|
293
|
+
let total = totalAttachedBytes;
|
|
294
|
+
let count = attachments.length;
|
|
295
|
+
const added: IDraftAttachment[] = [];
|
|
296
|
+
let notice: string | null = null;
|
|
297
|
+
for (const candidate of candidates) {
|
|
298
|
+
const outcome = evaluateCandidateFile(candidate, workspacePath, total, count);
|
|
299
|
+
if (outcome.kind === 'attached') {
|
|
300
|
+
added.push(outcome.attachment);
|
|
301
|
+
total += outcome.attachment.size;
|
|
302
|
+
count += 1;
|
|
303
|
+
} else {
|
|
304
|
+
notice = outcome.message;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (added.length > 0) setAttachments((current) => [...current, ...added]);
|
|
308
|
+
setAttachmentNotice(notice);
|
|
309
|
+
};
|
|
310
|
+
const removeAttachment = (id: string): void => {
|
|
311
|
+
setAttachments((current) => current.filter((a) => a.id !== id));
|
|
312
|
+
};
|
|
313
|
+
const toCandidateFromFile = (file: File): ICandidateFile => ({
|
|
314
|
+
name: file.name,
|
|
315
|
+
size: file.size,
|
|
316
|
+
mimeType: file.type || undefined,
|
|
317
|
+
absolutePath: getPathForFile ? getPathForFile(file) || undefined : undefined,
|
|
318
|
+
});
|
|
319
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
320
|
+
const handleAttachClick = (): void => {
|
|
321
|
+
if (!connected) return;
|
|
322
|
+
if (pickFiles) {
|
|
323
|
+
pickFiles()
|
|
324
|
+
.then((picked) =>
|
|
325
|
+
addCandidates(picked.map((f) => ({ name: f.name, size: f.size, absolutePath: f.path }))),
|
|
326
|
+
)
|
|
327
|
+
// The host's dialog IPC can reject (e.g. the window closed mid-pick) — say so rather than
|
|
328
|
+
// leaving an unhandled rejection and a button that silently did nothing (rule 5: never fail
|
|
329
|
+
// silently).
|
|
330
|
+
.catch(() => setAttachmentNotice('Could not open the file picker. Try again.'));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
fileInputRef.current?.click();
|
|
334
|
+
};
|
|
335
|
+
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
|
336
|
+
const files = Array.from(event.target.files ?? []);
|
|
337
|
+
event.target.value = ''; // allow picking the same (rejected) file again after fixing it
|
|
338
|
+
addCandidates(files.map(toCandidateFromFile));
|
|
339
|
+
};
|
|
340
|
+
const dragDepthRef = useRef(0);
|
|
341
|
+
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
|
|
342
|
+
const hasFilesDrag = (event: React.DragEvent): boolean =>
|
|
343
|
+
Array.from(event.dataTransfer?.types ?? []).includes('Files');
|
|
344
|
+
const onDragEnter = (event: React.DragEvent): void => {
|
|
345
|
+
if (!connected || !hasFilesDrag(event)) return;
|
|
346
|
+
event.preventDefault();
|
|
347
|
+
dragDepthRef.current += 1;
|
|
348
|
+
setIsDraggingFiles(true);
|
|
349
|
+
};
|
|
350
|
+
const onDragOver = (event: React.DragEvent): void => {
|
|
351
|
+
if (!connected || !hasFilesDrag(event)) return;
|
|
352
|
+
event.preventDefault(); // required for onDrop to fire
|
|
353
|
+
};
|
|
354
|
+
const onDragLeave = (): void => {
|
|
355
|
+
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
356
|
+
if (dragDepthRef.current === 0) setIsDraggingFiles(false);
|
|
357
|
+
};
|
|
358
|
+
const onDrop = (event: React.DragEvent): void => {
|
|
359
|
+
if (!connected || !hasFilesDrag(event)) return;
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
dragDepthRef.current = 0;
|
|
362
|
+
setIsDraggingFiles(false);
|
|
363
|
+
addCandidates(Array.from(event.dataTransfer?.files ?? []).map(toCandidateFromFile));
|
|
364
|
+
};
|
|
365
|
+
/** Choosing a command from the menu, by mouse or by keyboard (`acceptMenu` below). */
|
|
366
|
+
const chooseMenuItem = (item: ICommandMenuItem): void => {
|
|
367
|
+
if (isImmediateScreenCommand(item)) {
|
|
368
|
+
setDraft('');
|
|
369
|
+
onCommand(item.name);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
setDraft(`/${item.name} `);
|
|
373
|
+
};
|
|
374
|
+
/** Complete the highlighted name; a draft that already names it is sent instead. A command whose
|
|
375
|
+
* whole job is opening a screen runs at once either way — see `IMMEDIATE_SCREEN_COMMANDS`. */
|
|
376
|
+
const acceptMenu = (): boolean => {
|
|
377
|
+
const item = menu?.[selected];
|
|
378
|
+
if (!item) return false;
|
|
379
|
+
if (!isImmediateScreenCommand(item) && draft === `/${item.name}`) return false;
|
|
380
|
+
chooseMenuItem(item);
|
|
381
|
+
return true;
|
|
382
|
+
};
|
|
383
|
+
/** #3280 §2: Edit puts the queued text back in the draft and cancels the queue behind it (the wire
|
|
384
|
+
* has no per-message cancel — only a whole-queue clear). Offered only when exactly one prompt is
|
|
385
|
+
* queued: with more than one, `cancel-queue` would still drop every one of them, but only the
|
|
386
|
+
* shown prompt's text is known here, so "Edit" would silently lose the rest — the row offers only
|
|
387
|
+
* "Remove all" instead once `queued.count > 1`. */
|
|
388
|
+
const editQueued = (): void => {
|
|
389
|
+
if (!queued) return;
|
|
390
|
+
setDraft(queued.text);
|
|
391
|
+
onCancelQueue();
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div
|
|
396
|
+
className="relative flex-shrink-0"
|
|
397
|
+
onDragEnter={onDragEnter}
|
|
398
|
+
onDragOver={onDragOver}
|
|
399
|
+
onDragLeave={onDragLeave}
|
|
400
|
+
onDrop={onDrop}
|
|
401
|
+
>
|
|
402
|
+
{/* #3282 §4d rule 7: the drop target is announced even to someone who cannot drag a file —
|
|
403
|
+
the attach button beside the textarea is how they reach the same result. */}
|
|
404
|
+
<p id="composer-attach-hint" className="sr-only">
|
|
405
|
+
Drag files here, or use Attach files, to add them to your message.
|
|
406
|
+
</p>
|
|
407
|
+
{isDraggingFiles && (
|
|
408
|
+
<div
|
|
409
|
+
role="status"
|
|
410
|
+
aria-live="polite"
|
|
411
|
+
className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-[22px] border-2 border-dashed border-accent bg-accent/10 text-[14px] font-medium text-accent"
|
|
412
|
+
>
|
|
413
|
+
Drop to attach
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
416
|
+
{queued && (
|
|
417
|
+
<div
|
|
418
|
+
role="status"
|
|
419
|
+
aria-label="queued prompt"
|
|
420
|
+
className="gui-rise mb-2 flex items-center gap-3 rounded-2xl bg-card px-4 py-2 text-[13px]"
|
|
421
|
+
>
|
|
422
|
+
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
|
423
|
+
Queued: {queued.text}
|
|
424
|
+
{queued.count > 1 && (
|
|
425
|
+
<span className="text-subtle"> and {queued.count - 1} more</span>
|
|
426
|
+
)}
|
|
427
|
+
</span>
|
|
428
|
+
{queued.count === 1 ? (
|
|
429
|
+
<>
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
onClick={editQueued}
|
|
433
|
+
className="flex-shrink-0 rounded-lg px-2 py-1 text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
434
|
+
>
|
|
435
|
+
Edit
|
|
436
|
+
</button>
|
|
437
|
+
<button
|
|
438
|
+
type="button"
|
|
439
|
+
onClick={onCancelQueue}
|
|
440
|
+
className="flex-shrink-0 rounded-lg px-2 py-1 text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
441
|
+
>
|
|
442
|
+
Remove
|
|
443
|
+
</button>
|
|
444
|
+
</>
|
|
445
|
+
) : (
|
|
446
|
+
<button
|
|
447
|
+
type="button"
|
|
448
|
+
onClick={onCancelQueue}
|
|
449
|
+
className="flex-shrink-0 rounded-lg px-2 py-1 text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
450
|
+
>
|
|
451
|
+
Remove all
|
|
452
|
+
</button>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
)}
|
|
456
|
+
{menu && (
|
|
457
|
+
<div
|
|
458
|
+
role="listbox"
|
|
459
|
+
aria-label="commands"
|
|
460
|
+
className="gui-rise absolute bottom-full left-0 right-0 mb-2 max-h-[320px] overflow-y-auto rounded-2xl bg-popover p-1.5 shadow-2xl shadow-black/35"
|
|
461
|
+
>
|
|
462
|
+
{menu.map((item, index) => {
|
|
463
|
+
// #3282 §4e: "Commands" then "Skills" — a header appears once, above the first row of
|
|
464
|
+
// its group (the menu always lists every command before every skill, so a group's rows
|
|
465
|
+
// are contiguous).
|
|
466
|
+
const showGroupHeader = index === 0 || menuGroupLabel(menu[index - 1]!) !== menuGroupLabel(item);
|
|
467
|
+
return (
|
|
468
|
+
<div key={`${item.kind}:${item.name}`}>
|
|
469
|
+
{showGroupHeader && (
|
|
470
|
+
<div
|
|
471
|
+
role="presentation"
|
|
472
|
+
className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-subtle first:pt-1"
|
|
473
|
+
>
|
|
474
|
+
{menuGroupLabel(item)}
|
|
475
|
+
</div>
|
|
476
|
+
)}
|
|
477
|
+
<button
|
|
478
|
+
ref={(el) => {
|
|
479
|
+
menuOptionRefs.current[index] = el;
|
|
480
|
+
}}
|
|
481
|
+
type="button"
|
|
482
|
+
role="option"
|
|
483
|
+
aria-selected={index === selected}
|
|
484
|
+
onMouseEnter={() => setSelected(index)}
|
|
485
|
+
onMouseDown={(event) => {
|
|
486
|
+
event.preventDefault();
|
|
487
|
+
chooseMenuItem(item);
|
|
488
|
+
}}
|
|
489
|
+
className={`flex w-full items-baseline gap-3 rounded-xl px-3 py-2 text-left text-[14px] ${
|
|
490
|
+
index === selected ? 'bg-hover' : ''
|
|
491
|
+
}`}
|
|
492
|
+
>
|
|
493
|
+
<span className="flex-shrink-0 font-mono text-[13.5px] text-foreground">
|
|
494
|
+
/{item.name}
|
|
495
|
+
</span>
|
|
496
|
+
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
|
497
|
+
{item.description}
|
|
498
|
+
</span>
|
|
499
|
+
</button>
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
})}
|
|
503
|
+
</div>
|
|
504
|
+
)}
|
|
505
|
+
<form
|
|
506
|
+
className="rounded-[22px] bg-card px-2.5 pb-2 pt-2.5 shadow-[0_8px_30px_-12px_rgb(0_0_0/0.45)] transition-shadow focus-within:ring-2 focus-within:ring-ring"
|
|
507
|
+
onSubmit={(e) => {
|
|
508
|
+
e.preventDefault();
|
|
509
|
+
submit();
|
|
510
|
+
}}
|
|
511
|
+
>
|
|
512
|
+
{attachments.length > 0 && (
|
|
513
|
+
<ul
|
|
514
|
+
aria-label="attachments"
|
|
515
|
+
className="mb-1.5 flex flex-wrap gap-1.5 px-1 pt-0.5"
|
|
516
|
+
>
|
|
517
|
+
{attachments.map((attachment) => (
|
|
518
|
+
<li
|
|
519
|
+
key={attachment.id}
|
|
520
|
+
title={attachment.relativePath}
|
|
521
|
+
className="flex max-w-full items-center gap-1.5 rounded-lg bg-raised px-2 py-1 text-[12.5px] text-muted-foreground"
|
|
522
|
+
>
|
|
523
|
+
<Paperclip size={12} strokeWidth={1.75} aria-hidden="true" className="flex-shrink-0" />
|
|
524
|
+
<span className="max-w-[180px] truncate">{attachment.name}</span>
|
|
525
|
+
<button
|
|
526
|
+
type="button"
|
|
527
|
+
aria-label={`Remove ${attachment.name}`}
|
|
528
|
+
onClick={() => removeAttachment(attachment.id)}
|
|
529
|
+
className="flex-shrink-0 rounded-full p-0.5 hover:bg-hover hover:text-foreground"
|
|
530
|
+
>
|
|
531
|
+
<X size={12} strokeWidth={2} aria-hidden="true" />
|
|
532
|
+
</button>
|
|
533
|
+
</li>
|
|
534
|
+
))}
|
|
535
|
+
</ul>
|
|
536
|
+
)}
|
|
537
|
+
{attachmentNotice && (
|
|
538
|
+
<p role="status" aria-live="polite" className="mb-1.5 px-1.5 text-[12.5px] text-muted-foreground">
|
|
539
|
+
{attachmentNotice}
|
|
540
|
+
</p>
|
|
541
|
+
)}
|
|
542
|
+
<input
|
|
543
|
+
ref={fileInputRef}
|
|
544
|
+
type="file"
|
|
545
|
+
multiple
|
|
546
|
+
onChange={handleFileInputChange}
|
|
547
|
+
className="sr-only"
|
|
548
|
+
tabIndex={-1}
|
|
549
|
+
aria-hidden="true"
|
|
550
|
+
/>
|
|
551
|
+
<textarea
|
|
552
|
+
ref={textareaRef}
|
|
553
|
+
aria-label="message"
|
|
554
|
+
aria-describedby="composer-attach-hint"
|
|
555
|
+
rows={1}
|
|
556
|
+
value={draft}
|
|
557
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
558
|
+
onKeyDown={(e) => {
|
|
559
|
+
// #3280 §4: an Enter that only finishes an IME (Korean/Japanese/Chinese) composition must
|
|
560
|
+
// not submit or accept the menu — `isComposing` (or `keyCode` 229, on browsers that
|
|
561
|
+
// predate it) marks it; the keystroke is left alone so the browser commits the composition
|
|
562
|
+
// normally, matching the guard the free-text prompt field uses (#3280 §3).
|
|
563
|
+
if (e.key === 'Enter' && (e.nativeEvent.isComposing || e.keyCode === 229)) return;
|
|
564
|
+
if (menu) {
|
|
565
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
566
|
+
e.preventDefault();
|
|
567
|
+
const step = e.key === 'ArrowDown' ? 1 : -1;
|
|
568
|
+
setSelected((index) => (index + step + menu.length) % menu.length);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (e.key === 'Escape') {
|
|
572
|
+
e.preventDefault();
|
|
573
|
+
setDismissed(true);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if ((e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) && acceptMenu()) {
|
|
577
|
+
e.preventDefault();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// #3280 §2: Esc stops a running turn — but only once the menu (handled above) is out of
|
|
582
|
+
// the way, so dismissing the `/` menu never doubles as an abort. Not while disconnected:
|
|
583
|
+
// an abort could not reach the host either (issue #3280 §5).
|
|
584
|
+
if (e.key === 'Escape' && running && connected) {
|
|
585
|
+
e.preventDefault();
|
|
586
|
+
onStop();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
590
|
+
e.preventDefault();
|
|
591
|
+
submit();
|
|
592
|
+
}
|
|
593
|
+
}}
|
|
594
|
+
placeholder={`Ask ${identity.displayName} anything — type / for commands`}
|
|
595
|
+
className="block max-h-[220px] min-h-[48px] w-full resize-none bg-transparent px-2 py-1 text-[15px] leading-relaxed text-foreground [field-sizing:content] focus:outline-none"
|
|
596
|
+
/>
|
|
597
|
+
<div className="mt-1 flex items-center gap-1">
|
|
598
|
+
<button
|
|
599
|
+
type="button"
|
|
600
|
+
aria-label="Attach files"
|
|
601
|
+
// #3282 §2 (part 2): the attach button follows the same disconnected rule as the status
|
|
602
|
+
// chips and the `/` menu — a short "Reconnecting…" tooltip in place of its usual one.
|
|
603
|
+
title={connected ? 'Attach files' : 'Reconnecting…'}
|
|
604
|
+
disabled={!connected}
|
|
605
|
+
onClick={handleAttachClick}
|
|
606
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-hover hover:text-foreground disabled:text-subtle disabled:hover:bg-transparent"
|
|
607
|
+
>
|
|
608
|
+
<Paperclip size={16} strokeWidth={1.75} aria-hidden="true" />
|
|
609
|
+
</button>
|
|
610
|
+
<StatusRow
|
|
611
|
+
status={status}
|
|
612
|
+
catalog={catalog}
|
|
613
|
+
modelList={modelList}
|
|
614
|
+
onRequestModelList={onRequestModelList}
|
|
615
|
+
onManageProviders={onManageProviders}
|
|
616
|
+
onSilentCommand={onSilentCommand}
|
|
617
|
+
connected={connected}
|
|
618
|
+
/>
|
|
619
|
+
<button
|
|
620
|
+
type={running ? 'button' : 'submit'}
|
|
621
|
+
onClick={running ? onStop : undefined}
|
|
622
|
+
// Stop is unavailable while disconnected too: an abort could not reach the host either.
|
|
623
|
+
disabled={!connected || (!running && !draft.trim() && attachments.length === 0)}
|
|
624
|
+
aria-description={connected ? undefined : 'Not connected'}
|
|
625
|
+
className="ml-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-all hover:opacity-85 disabled:bg-raised disabled:text-subtle"
|
|
626
|
+
>
|
|
627
|
+
{running ? (
|
|
628
|
+
<Square size={13} strokeWidth={2} fill="currentColor" aria-hidden="true" />
|
|
629
|
+
) : (
|
|
630
|
+
<ArrowUp size={17} strokeWidth={2.25} aria-hidden="true" />
|
|
631
|
+
)}
|
|
632
|
+
<span className="sr-only">{running ? 'Stop' : 'Send'}</span>
|
|
633
|
+
</button>
|
|
634
|
+
</div>
|
|
635
|
+
</form>
|
|
636
|
+
</div>
|
|
637
|
+
);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The goal being pursued (`/goal`), above the composer while it is active — the objective, how far
|
|
642
|
+
* the turn budget has gone, and a way to stop it (`/goal cancel`).
|
|
643
|
+
*/
|
|
644
|
+
export function GoalBar({
|
|
645
|
+
status,
|
|
646
|
+
onStop,
|
|
647
|
+
}: {
|
|
648
|
+
status: TSessionStatus | null;
|
|
649
|
+
onStop: () => void;
|
|
650
|
+
}): React.ReactElement | null {
|
|
651
|
+
const goal = status?.goal;
|
|
652
|
+
if (!goal || goal.status !== 'active') return null;
|
|
653
|
+
const progress = goal.maxIterations > 0 ? Math.min(1, goal.iterations / goal.maxIterations) : 0;
|
|
654
|
+
return (
|
|
655
|
+
<div
|
|
656
|
+
role="status"
|
|
657
|
+
aria-label="goal"
|
|
658
|
+
className="gui-rise relative flex flex-shrink-0 items-center gap-3 overflow-hidden rounded-2xl bg-card px-4 py-2.5 text-[14px]"
|
|
659
|
+
>
|
|
660
|
+
<Target size={16} strokeWidth={1.75} className="flex-shrink-0 text-accent" />
|
|
661
|
+
<span className="flex-shrink-0 font-medium text-foreground">Goal</span>
|
|
662
|
+
<span className="min-w-0 flex-1 truncate text-muted-foreground">{goal.objective}</span>
|
|
663
|
+
<span className="flex-shrink-0 text-[13px] tabular-nums text-subtle">
|
|
664
|
+
{goal.iterations}/{goal.maxIterations}
|
|
665
|
+
</span>
|
|
666
|
+
<button
|
|
667
|
+
type="button"
|
|
668
|
+
aria-label="Stop goal"
|
|
669
|
+
onClick={onStop}
|
|
670
|
+
className="flex flex-shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
671
|
+
>
|
|
672
|
+
<Square size={11} fill="currentColor" aria-hidden="true" />
|
|
673
|
+
Stop
|
|
674
|
+
</button>
|
|
675
|
+
<span
|
|
676
|
+
aria-hidden="true"
|
|
677
|
+
className="absolute bottom-0 left-0 h-[2px] bg-accent/70 transition-[width]"
|
|
678
|
+
style={{ width: `${progress * 100}%` }}
|
|
679
|
+
/>
|
|
680
|
+
</div>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// `StatusRow` (model/mode/effort pop-up menus) and its `ContextRing` moved to `StatusControls.tsx`
|
|
685
|
+
// (#3282 §2 part 2) — imported above.
|