@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +400 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +715 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +7923 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +7973 -0
  9. package/dist/node/index.d.cts +715 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +715 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +7923 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +124 -0
  17. package/package.json +99 -3
  18. package/src/client/runtime-host.ts +84 -0
  19. package/src/client/ws-session-client.ts +131 -0
  20. package/src/clipboard.ts +81 -0
  21. package/src/components/AgentActivityPanel.tsx +371 -0
  22. package/src/components/AgentSwitcherSheet.tsx +93 -0
  23. package/src/components/Brand.tsx +33 -0
  24. package/src/components/Composer.tsx +685 -0
  25. package/src/components/ConversationView.tsx +772 -0
  26. package/src/components/Dialog.tsx +230 -0
  27. package/src/components/DiffLines.tsx +81 -0
  28. package/src/components/ExecutionDetailSheet.tsx +142 -0
  29. package/src/components/HelpSheet.tsx +104 -0
  30. package/src/components/PermissionPrompt.tsx +661 -0
  31. package/src/components/PersonalUsageDashboard.tsx +39 -0
  32. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  33. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  34. package/src/components/PopupMenu.tsx +166 -0
  35. package/src/components/ProjectPanel.tsx +268 -0
  36. package/src/components/SessionMonitor.tsx +180 -0
  37. package/src/components/SessionSidebar.tsx +429 -0
  38. package/src/components/SessionSurface.tsx +455 -0
  39. package/src/components/SessionSurfaceChrome.tsx +316 -0
  40. package/src/components/SettingsFields.tsx +159 -0
  41. package/src/components/SettingsGeneralSection.tsx +49 -0
  42. package/src/components/SettingsMcpSection.tsx +157 -0
  43. package/src/components/SettingsPermissionsSection.tsx +137 -0
  44. package/src/components/SettingsPluginsSection.tsx +165 -0
  45. package/src/components/SettingsProvidersSection.tsx +211 -0
  46. package/src/components/SettingsScreen.tsx +340 -0
  47. package/src/components/StatusControls.tsx +294 -0
  48. package/src/components/composer-attachments.ts +246 -0
  49. package/src/components/personal-usage-dashboard-types.ts +26 -0
  50. package/src/driver-labels.ts +88 -0
  51. package/src/execution-entry-status.ts +55 -0
  52. package/src/goal-view.ts +19 -0
  53. package/src/hooks/command-menu.ts +65 -0
  54. package/src/hooks/excluded-commands.ts +52 -0
  55. package/src/hooks/prompt-state.ts +83 -0
  56. package/src/hooks/server-message-handling.ts +92 -0
  57. package/src/hooks/session-client-types.ts +329 -0
  58. package/src/hooks/ui-intent-state.ts +74 -0
  59. package/src/hooks/use-agent-switcher-state.ts +125 -0
  60. package/src/hooks/use-execution-detail.ts +118 -0
  61. package/src/hooks/use-media-query.ts +27 -0
  62. package/src/hooks/use-model-list.ts +45 -0
  63. package/src/hooks/use-personal-usage.ts +138 -0
  64. package/src/hooks/use-project-panel.ts +117 -0
  65. package/src/hooks/use-schedules-state.ts +92 -0
  66. package/src/hooks/use-session-directory.ts +202 -0
  67. package/src/hooks/use-settings-state.ts +115 -0
  68. package/src/hooks/useSessionClient.ts +991 -0
  69. package/src/index.ts +66 -0
  70. package/src/product-identity.tsx +26 -0
  71. package/src/schedule-view.ts +92 -0
  72. package/src/styles/surface.css +231 -0
  73. package/src/styles/surface.css.d.mts +1 -0
  74. package/src/styles/theme.css +38 -0
  75. package/src/styles/theme.css.d.mts +1 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * #3282 §4d — pure logic behind the composer's attach button and drag-and-drop, kept apart from
3
+ * `Composer.tsx` so it is testable without React or a DOM.
4
+ *
5
+ * The runtime resolves `@relative/path` references already (`prompt-file-reference-resolver.ts` in
6
+ * agent-framework): inside the workspace only, 64 KiB per file, 256 KiB total per prompt, 8 references
7
+ * per prompt, and (as of this change) refuses a binary file outright rather than mangling it. This
8
+ * module mirrors those same limits client-side so a file the runtime would refuse is never even
9
+ * attached: the running size and count totals, and a MIME/extension heuristic that refuses a
10
+ * likely-binary file (images included) up front, before it ever becomes a chip. It does not (and
11
+ * cannot) reuse the resolver's code directly: that package is Node-only, and this one ships to a plain
12
+ * browser tab too (agent-ui-web SPEC: no dependency on agent-framework).
13
+ */
14
+
15
+ /** Mirrors `DEFAULT_MAX_FILE_BYTES` in `packages/agent-framework/src/context/prompt-file-reference-resolver.ts`. */
16
+ export const MAX_ATTACHMENT_FILE_BYTES = 64 * 1024;
17
+ /** Mirrors `DEFAULT_MAX_TOTAL_BYTES` in the same resolver. */
18
+ export const MAX_ATTACHMENT_TOTAL_BYTES = 256 * 1024;
19
+ /**
20
+ * Mirrors `DEFAULT_MAX_REFERENCES` in the same resolver — not imported directly (agent-ui-web has no
21
+ * dependency on agent-framework; see the module comment above), so this is a named constant kept in
22
+ * sync by hand. The runtime counts every `@`-reference in the final prompt, including one a person
23
+ * typed by hand, so a batch that fits under this count can still be refused server-side if the typed
24
+ * text already carries references of its own — this client-side count only catches the common case
25
+ * (the chips alone) before the round trip.
26
+ */
27
+ export const MAX_ATTACHMENT_COUNT = 8;
28
+
29
+ /** Shown whenever a file cannot be resolved to a path inside the workspace — rule 3 (#3282 §4). */
30
+ export const ATTACHMENT_NO_PATH_NOTICE = 'Only files inside this project folder can be attached.';
31
+
32
+ /** Shown once the running count of attachments would pass `MAX_ATTACHMENT_COUNT`. */
33
+ export const ATTACHMENT_COUNT_LIMIT_NOTICE = `You can attach up to ${MAX_ATTACHMENT_COUNT} files to one message.`;
34
+
35
+ /**
36
+ * Shown whenever a file looks binary (images included) — refused outright, never attached. Images are
37
+ * not sent as native content in this version (the wire and the interactive-session runtime do not
38
+ * carry `parts` from a submitted prompt yet), and the `@`-reference resolver has no way to carry
39
+ * binary bytes as text either, so there is no path that would work today.
40
+ */
41
+ export const ATTACHMENT_NON_TEXT_NOTICE = "Images and other non-text files can't be attached yet.";
42
+
43
+ /** One file the composer has turned into an `@`-reference chip, kept in the draft until sent or removed. */
44
+ export interface IDraftAttachment {
45
+ readonly id: string;
46
+ readonly name: string;
47
+ /** Workspace-relative, forward-slash separated — what `@` names once inserted into the prompt. */
48
+ readonly relativePath: string;
49
+ readonly size: number;
50
+ }
51
+
52
+ /**
53
+ * One file the desktop host's native "Attach files" dialog returned (#3282 §4d). Kept in structural
54
+ * sync with `IPickedFile` in `apps/agent-gui-web/src/gui-host.ts` and
55
+ * `apps/agent-app/electron/sidecar.ts` — the three are not imported across the Electron/browser/
56
+ * presentation package boundaries, same as this codebase already does for the trust question.
57
+ */
58
+ export interface IPickedFile {
59
+ readonly path: string;
60
+ readonly name: string;
61
+ readonly size: number;
62
+ }
63
+
64
+ /** A file offered to the composer, from a drop, the plain HTML picker, or the desktop dialog. */
65
+ export interface ICandidateFile {
66
+ readonly name: string;
67
+ readonly size: number;
68
+ readonly mimeType?: string;
69
+ /** The real filesystem path, when the host could resolve one (desktop only). */
70
+ readonly absolutePath?: string;
71
+ }
72
+
73
+ export type TAttachOutcome =
74
+ | { readonly kind: 'attached'; readonly attachment: IDraftAttachment }
75
+ | { readonly kind: 'rejected'; readonly message: string };
76
+
77
+ function normalizeSeparators(path: string): string {
78
+ return path.replaceAll('\\', '/');
79
+ }
80
+
81
+ /**
82
+ * Collapses `.` and `..` segments the way a filesystem path would, without touching disk — this
83
+ * module ships to a plain browser tab too, so it cannot use `node:path`. Applied to both sides before
84
+ * `relativeWorkspacePath` compares them, so a non-canonical path (one carrying `./`/`../` noise, e.g.
85
+ * from a symlink or an odd OS report) is judged by what it actually resolves to, not by a raw string
86
+ * prefix match that a `..` segment could otherwise defeat (a target that merely *starts with* the
87
+ * root string, `../` and all, must not be treated as inside it just because the raw text lines up).
88
+ */
89
+ function normalizePathSegments(path: string): string {
90
+ const isRooted = path.startsWith('/');
91
+ const out: string[] = [];
92
+ for (const segment of path.split('/')) {
93
+ if (segment === '' || segment === '.') continue;
94
+ if (segment === '..') {
95
+ if (out.length > 0 && out[out.length - 1] !== '..') out.pop();
96
+ else if (!isRooted) out.push('..');
97
+ // A rooted path cannot go above its own root — drop silently, matching path.normalize.
98
+ continue;
99
+ }
100
+ out.push(segment);
101
+ }
102
+ return (isRooted ? '/' : '') + out.join('/');
103
+ }
104
+
105
+ /**
106
+ * The file's path relative to the workspace root, or `null` when it is not inside it (an absolute
107
+ * root is required; a relative/empty `workspacePath` never counts as "inside" anything). Both sides
108
+ * are canonicalized first (see `normalizePathSegments`), then compared exactly (case-sensitive): on a
109
+ * case-insensitive filesystem a differently-cased root is treated as outside rather than guessed at —
110
+ * the composer would rather show the plain notice than attach the wrong file.
111
+ */
112
+ export function relativeWorkspacePath(
113
+ workspacePath: string | undefined,
114
+ absolutePath: string,
115
+ ): string | null {
116
+ if (!workspacePath) return null;
117
+ const root = normalizePathSegments(normalizeSeparators(workspacePath));
118
+ if (!root || root === '/') return null;
119
+ const target = normalizePathSegments(normalizeSeparators(absolutePath));
120
+ if (target === root) return null; // the root itself is a directory, not an attachable file
121
+ const prefix = `${root}/`;
122
+ if (!target.startsWith(prefix)) return null;
123
+ const relative = target.slice(prefix.length);
124
+ return relative ? relative : null;
125
+ }
126
+
127
+ /**
128
+ * A MIME type the browser reports for genuinely text-ish content — never refused as binary: the type
129
+ * starts with `text/`, or ends with `+json`, `+xml`, `/json`, or `/xml` (e.g. `application/ld+json`,
130
+ * `image/svg+xml`). Kept as two separately anchored tests rather than one alternation — `|` binds
131
+ * looser than `^`/`$`, so a single pattern like `/^text\/|(?:\+|\/)(?:json|xml)$/` reads ambiguously
132
+ * (flagged by CodeQL as misleading operator precedence) even though each anchor was already scoped to
133
+ * the alternative it belongs to.
134
+ */
135
+ function isTextyMimeType(mimeType: string): boolean {
136
+ return /^text\//.test(mimeType) || /[+/](?:json|xml)$/.test(mimeType);
137
+ }
138
+ const BINARY_EXTENSIONS = new Set([
139
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tiff', 'heic',
140
+ 'pdf', 'zip', 'tar', 'gz', 'tgz', '7z', 'rar',
141
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'class', 'wasm',
142
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
143
+ 'mp3', 'mp4', 'mov', 'avi', 'wav', 'ogg', 'flac', 'm4a',
144
+ ]);
145
+
146
+ /**
147
+ * Whether the file looks binary (never sniffs bytes — MIME/extension only). When the browser reports
148
+ * a MIME type (a drop, or the plain HTML picker), ANY non-text type refuses it — images, PDFs,
149
+ * archives, and anything else a browser did not call `text/…`/JSON/XML. The desktop dialog gives no
150
+ * MIME type at all, so a picked file falls back to the extension denylist.
151
+ */
152
+ export function looksBinary(name: string, mimeType: string | undefined): boolean {
153
+ if (mimeType) return !isTextyMimeType(mimeType);
154
+ const dot = name.lastIndexOf('.');
155
+ if (dot < 0) return false;
156
+ return BINARY_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
157
+ }
158
+
159
+ /** The same terminator set `prompt-file-reference-parser.ts` stops an `@`-reference token at. */
160
+ const UNSAFE_REFERENCE_CHARS = /[\s)\]}>,;"'`]/u;
161
+ /**
162
+ * `prompt-file-reference-parser.ts`'s `stripTrailingPunctuation` also trims a trailing `.,:;!?` off
163
+ * every captured `@token` — `,`/`;` are already covered above (they can appear anywhere in that
164
+ * set), but a path merely *ending* in `.`/`:`/`!`/`?` (fine mid-string — most files have a `.`) would
165
+ * otherwise be silently truncated by the runtime, so the chip would name a file the reference can
166
+ * never actually resolve.
167
+ */
168
+ const UNSAFE_TRAILING_CHAR = /[.,:;!?]$/u;
169
+
170
+ function formatBytes(bytes: number): string {
171
+ return bytes >= 1024 ? `${Math.round(bytes / 1024)} KB` : `${bytes} B`;
172
+ }
173
+
174
+ function randomId(): string {
175
+ const cryptoObj = typeof crypto !== 'undefined' ? crypto : undefined;
176
+ if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
177
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
178
+ }
179
+
180
+ /**
181
+ * Decide what a candidate file becomes: a chip, or a plain rejection message. `existingTotalBytes` is
182
+ * the sum of what is already attached and `existingCount` is how many chips there already are, so the
183
+ * running 256 KiB total and the 8-file count are both enforced before the file is added rather than
184
+ * after. The count is checked first, matching the runtime's own `checkReferenceBudget` (which also
185
+ * checks its reference-count limit before it ever looks at the path) — once the cap is reached, every
186
+ * further file gets the same one clear reason rather than a mix of different messages depending on
187
+ * which check happens to run first for each one.
188
+ */
189
+ export function evaluateCandidateFile(
190
+ file: ICandidateFile,
191
+ workspacePath: string | undefined,
192
+ existingTotalBytes: number,
193
+ existingCount: number,
194
+ ): TAttachOutcome {
195
+ if (existingCount >= MAX_ATTACHMENT_COUNT) {
196
+ return { kind: 'rejected', message: ATTACHMENT_COUNT_LIMIT_NOTICE };
197
+ }
198
+ if (!file.absolutePath) return { kind: 'rejected', message: ATTACHMENT_NO_PATH_NOTICE };
199
+ const relativePath = relativeWorkspacePath(workspacePath, file.absolutePath);
200
+ if (relativePath === null) return { kind: 'rejected', message: ATTACHMENT_NO_PATH_NOTICE };
201
+ if (UNSAFE_REFERENCE_CHARS.test(relativePath) || UNSAFE_TRAILING_CHAR.test(relativePath)) {
202
+ return {
203
+ kind: 'rejected',
204
+ message: `"${file.name}" has a character (such as a space, or a period/punctuation mark at the end) the @file syntax can't carry — rename it to attach it.`,
205
+ };
206
+ }
207
+ // Refused outright, never mangled: the runtime's own @-reference resolver now refuses a binary
208
+ // file the same way (a NUL byte in the first 8 KiB) — this just says so before the round trip.
209
+ if (looksBinary(file.name, file.mimeType)) {
210
+ return { kind: 'rejected', message: ATTACHMENT_NON_TEXT_NOTICE };
211
+ }
212
+ if (file.size > MAX_ATTACHMENT_FILE_BYTES) {
213
+ return {
214
+ kind: 'rejected',
215
+ message: `${file.name} is larger than ${formatBytes(MAX_ATTACHMENT_FILE_BYTES)} and cannot be attached.`,
216
+ };
217
+ }
218
+ if (existingTotalBytes + file.size > MAX_ATTACHMENT_TOTAL_BYTES) {
219
+ return {
220
+ kind: 'rejected',
221
+ message: `Attachments cannot total more than ${formatBytes(MAX_ATTACHMENT_TOTAL_BYTES)}.`,
222
+ };
223
+ }
224
+ return {
225
+ kind: 'attached',
226
+ attachment: { id: randomId(), name: file.name, relativePath, size: file.size },
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Inserts each chip as the runtime's existing `@relative/path` reference syntax — rule 2 (#3282 §4).
232
+ * Always prefixed with `./`: the parser's `isPathLikeReference` only treats a token as a file
233
+ * reference when it has a `.` somewhere or an explicit relative-path prefix, so a dotless name like
234
+ * `Makefile`, `Dockerfile` or `LICENSE` — common, useful, and never refused for lacking one — would
235
+ * otherwise be sent as plain text with no file and no diagnostic (follow-up to #3282 §4d). `./` makes
236
+ * every path path-like regardless of its name, and the resolver collapses it back to the plain
237
+ * relative path before use.
238
+ */
239
+ export function buildPromptWithAttachments(
240
+ prompt: string,
241
+ attachments: readonly IDraftAttachment[],
242
+ ): string {
243
+ if (attachments.length === 0) return prompt;
244
+ const refs = attachments.map((attachment) => `@./${attachment.relativePath}`).join(' ');
245
+ return prompt ? `${prompt}\n\n${refs}` : refs;
246
+ }
@@ -0,0 +1,26 @@
1
+ import type { IWsSessionState } from '../hooks/useSessionClient.js';
2
+
3
+ export type TUsageBreakdown = 'model' | 'provider' | 'surface' | 'source' | 'activity';
4
+
5
+ export type TPersonalUsageDashboardState = Pick<
6
+ IWsSessionState,
7
+ | 'personalUsageStatus'
8
+ | 'personalUsageReport'
9
+ | 'personalUsageError'
10
+ | 'requestPersonalUsage'
11
+ | 'storedSessionUsageStatus'
12
+ | 'storedSessionUsageReport'
13
+ | 'storedSessionUsageSessionId'
14
+ | 'storedSessionUsageError'
15
+ | 'requestStoredSessionUsage'
16
+ | 'currentSessionUsageStatus'
17
+ | 'currentSessionUsageReport'
18
+ | 'requestCurrentSessionUsage'
19
+ // The dashboard names a session from this workspace's own local listing (see `sessionTitle()` in
20
+ // `SessionSidebar.tsx`) instead of any content carried in the report itself.
21
+ | 'sessionListing'
22
+ >;
23
+
24
+ export type TPersonalUsageReport = NonNullable<TPersonalUsageDashboardState['personalUsageReport']>;
25
+
26
+ export type TPersonalUsageDimension = TPersonalUsageReport['byModel'][number];
@@ -0,0 +1,88 @@
1
+ /**
2
+ * #3289 §3 — plain, human labels for a REMOTE-014 E5 driver id, and whether one names the SAME KIND
3
+ * of surface as this connection's own. Shared by every place the GUI shows who drove a turn (a
4
+ * message, a permission/ask prompt): the person's own input never carries a label, and a turn from a
5
+ * different kind of surface does — in words, never a raw id.
6
+ *
7
+ * The server hands every WebSocket connection of one `--serve` process the SAME driver id (there is
8
+ * no per-connection id), so two browser tabs of one local page cannot be told apart — and are the
9
+ * same person anyway. A label therefore names the KIND of surface a turn came from (terminal, desktop
10
+ * app, browser, remote device), not a specific window, and is shown only when that kind differs from
11
+ * this window's own.
12
+ */
13
+
14
+ import { AGENT_DRIVER_ID, OWNER_DRIVER_ID } from '@robota-sdk/agent-interface-session';
15
+
16
+ import type { TDriverId } from '@robota-sdk/agent-interface-session';
17
+
18
+ /**
19
+ * `remote` (a plain WS remote connection's own id, `'remote:ws'`) and `peer` (an unrelated REMOTE-014
20
+ * mesh peer's turn, `'peer:<sessionId>'`) are kept as DIFFERENT kinds even though both render the same
21
+ * label text: a plain WS viewer's own id is `'remote:ws'`, and folding a peer's turn into that same
22
+ * kind would make `isSameSurface` treat a co-driver's turn as this connection's own and hide it — the
23
+ * exact class of bug this module exists to prevent.
24
+ */
25
+ type TSurfaceKind = 'terminal' | 'desktop-app' | 'browser' | 'remote' | 'peer';
26
+
27
+ /**
28
+ * The kind of surface a driver id names. `null` for an id this function does not recognize — it still
29
+ * gets a label (see `humanDriverLabel`), just not one claimed to match anything else's kind.
30
+ */
31
+ function surfaceKindOf(driverId: TDriverId): TSurfaceKind | null {
32
+ if (driverId.startsWith('attach:')) return 'terminal';
33
+ if (driverId === 'app') return 'desktop-app';
34
+ if (driverId === 'browser') return 'browser';
35
+ if (driverId === 'remote:ws') return 'remote';
36
+ if (driverId.startsWith('peer:')) return 'peer';
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * True when `driverId` names the reserved local-operator id, or a surface of the SAME KIND as this
42
+ * connection's own (once learned) — so this window never labels its own kind of turn as a co-driver's.
43
+ * The agent's own wake-ups (`AGENT_DRIVER_ID`) are never "the same surface": automatic is always
44
+ * labelled, even though no human window could ever learn that as its own id.
45
+ */
46
+ export function isSameSurface(driverId: TDriverId, ownDriverId: TDriverId | null): boolean {
47
+ if (driverId === OWNER_DRIVER_ID) return true;
48
+ if (driverId === AGENT_DRIVER_ID) return false;
49
+ if (ownDriverId === null) return false;
50
+ if (driverId === ownDriverId) return true;
51
+ const kind = surfaceKindOf(driverId);
52
+ return kind !== null && kind === surfaceKindOf(ownDriverId);
53
+ }
54
+
55
+ /**
56
+ * A plain noun phrase for the kind of surface that drove a turn which was not this connection's own.
57
+ * Never returns a raw id: an id this function does not recognize still gets the least presumptuous
58
+ * label ("another surface") rather than being echoed back.
59
+ */
60
+ export function humanDriverLabel(driverId: TDriverId): string {
61
+ const kind = surfaceKindOf(driverId);
62
+ switch (kind) {
63
+ case 'terminal':
64
+ return 'the terminal';
65
+ case 'desktop-app':
66
+ return 'the desktop app';
67
+ case 'browser':
68
+ return 'the browser';
69
+ case 'remote':
70
+ case 'peer':
71
+ return 'a remote device';
72
+ default:
73
+ return 'another surface';
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The ready-to-render attribution phrase: "from <label>" for a co-driver, or "Automatic — loop"
79
+ * for the agent's own wake-ups — "from automatic" reads as a typo, not a source. #3288 §1: every
80
+ * `AGENT_DRIVER_ID` turn is, today, a loop's own injected iteration (fixed or self-paced —
81
+ * `requestWakeup` in `interactive-session.ts` is the one place that stamps this id); if a
82
+ * differently-sourced agent-wakeup turn is ever added, this label needs to name that source
83
+ * instead of assuming "loop".
84
+ */
85
+ export function driverAttributionText(driverId: TDriverId): string {
86
+ if (driverId === AGENT_DRIVER_ID) return 'Automatic — loop';
87
+ return `from ${humanDriverLabel(driverId)}`;
88
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * #3288 §1: one true-status label for an execution-workspace entry, shared by the Agents panel row
3
+ * and its detail sheet so the two never say something different about the same entry.
4
+ *
5
+ * A completed task whose result still reports a refused tool call is not "Done" — it is "Needs
6
+ * permission" (retrospective: the run is over, but something in it was refused). That is distinct
7
+ * from `attention === 'permission'`, which means a turn is PARKED waiting for an answer right now
8
+ * ("Needs you").
9
+ */
10
+ import type {
11
+ IExecutionWorkspaceEntry,
12
+ TExecutionAttention,
13
+ TExecutionWorkspaceStatus,
14
+ } from '@robota-sdk/agent-interface-execution';
15
+
16
+ export type TExecutionStatusTone = 'warning' | 'destructive' | 'success' | 'muted';
17
+
18
+ export interface IExecutionStatusLabel {
19
+ readonly label: string;
20
+ readonly tone: TExecutionStatusTone;
21
+ }
22
+
23
+ export function hadDeniedToolCalls(
24
+ status: TExecutionWorkspaceStatus,
25
+ deniedToolCalls: number | undefined,
26
+ ): boolean {
27
+ return status === 'completed' && (deniedToolCalls ?? 0) > 0;
28
+ }
29
+
30
+ export function describeExecutionEntryStatus(
31
+ entry: Pick<IExecutionWorkspaceEntry, 'status' | 'attention' | 'deniedToolCalls'>,
32
+ ): IExecutionStatusLabel | null {
33
+ return describeExecutionStatus(entry.status, entry.attention, entry.deniedToolCalls);
34
+ }
35
+
36
+ export function describeExecutionStatus(
37
+ status: TExecutionWorkspaceStatus,
38
+ attention: TExecutionAttention,
39
+ deniedToolCalls: number | undefined,
40
+ ): IExecutionStatusLabel | null {
41
+ if (attention === 'permission' || status === 'waiting_permission') {
42
+ return { label: 'Needs you', tone: 'warning' };
43
+ }
44
+ if (attention === 'failed' || status === 'failed') {
45
+ return { label: 'Failed', tone: 'destructive' };
46
+ }
47
+ if (hadDeniedToolCalls(status, deniedToolCalls)) {
48
+ return { label: 'Needs permission', tone: 'warning' };
49
+ }
50
+ if (status === 'completed') return { label: 'Done', tone: 'muted' };
51
+ if (status === 'cancelled') return { label: 'Stopped', tone: 'muted' };
52
+ if (status === 'queued') return { label: 'Queued', tone: 'muted' };
53
+ if (status === 'running') return { label: 'Running', tone: 'muted' };
54
+ return null;
55
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * #3282 §4 part b-3 — the Agents panel's Goal row: the objective, and its status in plain words.
3
+ */
4
+
5
+ import type { IGoalState } from '@robota-sdk/agent-interface-session';
6
+
7
+ const STOP_REASON_LABELS: Record<NonNullable<IGoalState['stopReason']>, string> = {
8
+ satisfied: 'Satisfied',
9
+ 'max-iterations': 'Stopped — reached the iteration limit',
10
+ cancelled: 'Cancelled',
11
+ 'no-progress': 'Stopped — no progress',
12
+ };
13
+
14
+ /** The goal's status in plain words — never the raw `status`/`stopReason` identifiers. */
15
+ export function describeGoalStatus(goal: IGoalState): string {
16
+ if (goal.status === 'active') return 'Active';
17
+ if (goal.status === 'satisfied') return 'Satisfied';
18
+ return goal.stopReason ? STOP_REASON_LABELS[goal.stopReason] : 'Stopped';
19
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * #3186 — what the composer's `/` menu offers for a draft. Pure, so the matching is testable without
3
+ * rendering: the TUI's autocomplete offers commands and skills the same way.
4
+ *
5
+ * #3282 §4e: a command the GUI cannot run at all (`isExcludedCommand`) never appears here — there is
6
+ * nothing useful for choosing it to do, and typing it by hand is answered separately (see
7
+ * `excluded-commands.ts`, `useSessionClient.ts`). Commands are listed before skills, each in its own
8
+ * group, so the menu can head them "Commands" and "Skills".
9
+ */
10
+
11
+ import { isExcludedCommand } from './excluded-commands.js';
12
+
13
+ import type { TCommandCatalog } from './session-client-types.js';
14
+
15
+ export interface ICommandMenuItem {
16
+ readonly name: string;
17
+ readonly description: string;
18
+ readonly kind: 'command' | 'skill';
19
+ }
20
+
21
+ /** How many rows the menu shows before it scrolls — a CSS sizing constant, not a data limit. */
22
+ export const COMMAND_MENU_VISIBLE_ROWS = 8;
23
+
24
+ /** A generous ceiling on total matches, so a huge personal skill folder can never make the menu unusable. */
25
+ const COMMAND_MENU_MAX_MATCHES = 200;
26
+
27
+ /**
28
+ * The menu for a draft, or null when no menu applies: the draft is not a bare `/name` being typed
29
+ * (it has arguments, or does not start with `/`), or nothing matches. Within each group (commands,
30
+ * then skills), names that start with what was typed come first, then names that merely contain it.
31
+ */
32
+ export function commandMenuFor(
33
+ catalog: TCommandCatalog | null,
34
+ draft: string,
35
+ ): readonly ICommandMenuItem[] | null {
36
+ if (!catalog || !draft.startsWith('/') || /\s/u.test(draft)) return null;
37
+ const query = draft.slice(1).toLowerCase();
38
+ const commandItems: ICommandMenuItem[] = catalog.commands
39
+ .filter((c) => !isExcludedCommand(c))
40
+ .map((c) => ({ name: c.name, description: c.description, kind: 'command' as const }));
41
+ const skillItems: ICommandMenuItem[] = catalog.skills
42
+ .filter((s) => s.userInvocable)
43
+ .map((s) => ({ name: s.name, description: s.description, kind: 'skill' as const }));
44
+ const matched = [...matchGroup(commandItems, query), ...matchGroup(skillItems, query)].slice(
45
+ 0,
46
+ COMMAND_MENU_MAX_MATCHES,
47
+ );
48
+ return matched.length > 0 ? matched : null;
49
+ }
50
+
51
+ /** One group's matches: names starting with the query, then names merely containing it, each alphabetical. */
52
+ function matchGroup(
53
+ items: readonly ICommandMenuItem[],
54
+ query: string,
55
+ ): readonly ICommandMenuItem[] {
56
+ const starts = items.filter((i) => i.name.toLowerCase().startsWith(query));
57
+ const contains = items.filter(
58
+ (i) => !i.name.toLowerCase().startsWith(query) && i.name.toLowerCase().includes(query),
59
+ );
60
+ return [...starts.sort(byName), ...contains.sort(byName)];
61
+ }
62
+
63
+ function byName(a: ICommandMenuItem, b: ICommandMenuItem): number {
64
+ return a.name.localeCompare(b.name);
65
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * #3282 §4e — built-in commands left out of the GUI on purpose (the design decisions in
3
+ * `packages/agent-gui-web/docs/SPEC.md` group them by reason). The `/` menu never lists one of these
4
+ * (`command-menu.ts`), and typing one by hand anyway is caught before it reaches the session
5
+ * (`useSessionClient.ts`'s `send`) and answered with the plain sentence here — never the session's own
6
+ * refusal, and never the generic "not available on this surface" line CMD-004 uses for a screen this
7
+ * surface simply has not built yet (see `ui-intent-state.ts`).
8
+ */
9
+
10
+ import type { TCommandRunner, TCommandSurface } from '@robota-sdk/agent-interface-command';
11
+
12
+ /** The minimum a catalog entry needs for the exclusion check below. */
13
+ export interface IExcludableCommandEntry {
14
+ readonly name: string;
15
+ readonly runner?: TCommandRunner;
16
+ readonly surfaces?: readonly TCommandSurface[];
17
+ }
18
+
19
+ /**
20
+ * One plain sentence per excluded command, naming what to use instead — never the command's own
21
+ * name again, never "not available on this surface". `theme`, `keybindings`, `editor` and `shell`
22
+ * also declare `runner: 'client', surfaces: ['terminal']` server-side (a second, independent way to
23
+ * reach the same "excluded" answer, checked by {@link isExcludedCommand} below); `statusline`,
24
+ * `exit` and `devices` do not, and are excluded here only because the GUI has its own, better answer.
25
+ */
26
+ const EXCLUDED_COMMAND_MESSAGES: ReadonlyMap<string, string> = new Map([
27
+ ['theme', 'This app follows your system appearance.'],
28
+ ['keybindings', 'This app uses standard keyboard shortcuts here.'],
29
+ ['editor', 'Type your message directly in the composer.'],
30
+ ['statusline', 'The GUI shows session status in its own status row, not a terminal status line.'],
31
+ ['shell', 'Use the Terminal app for shell commands.'],
32
+ ['exit', 'Close the window or tab to end this session; delete it from the sidebar to remove it for good.'],
33
+ ['devices', 'Device pairing is managed from the terminal for now.'],
34
+ ]);
35
+
36
+ /** A terminal-client command with no curated sentence above still gets a plain, honest one. */
37
+ const GENERIC_CLIENT_MESSAGE = 'This command runs in the host terminal.';
38
+
39
+ /**
40
+ * Whether a catalog entry is left out of the GUI: named above, or declared `runner: 'client'` for
41
+ * surfaces that do not include `'gui'` (a client-only command the GUI cannot run itself, whether or
42
+ * not this file has a curated sentence for it yet).
43
+ */
44
+ export function isExcludedCommand(entry: IExcludableCommandEntry): boolean {
45
+ if (EXCLUDED_COMMAND_MESSAGES.has(entry.name)) return true;
46
+ return entry.runner === 'client' && !(entry.surfaces ?? []).includes('gui');
47
+ }
48
+
49
+ /** The plain sentence for an excluded command name — a safe generic one when none is curated. */
50
+ export function excludedCommandMessage(name: string): string {
51
+ return EXCLUDED_COMMAND_MESSAGES.get(name) ?? GENERIC_CLIENT_MESSAGE;
52
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pure prompt-state derivation for the web UI (REMOTE-009 Step 4 / REMOTE-007 render+answer).
3
+ *
4
+ * The paired browser is the session OWNER (local == remote), so it must render and ANSWER the owner's own
5
+ * permission/ask prompts. The server carries `permission_request`/`ask_request` (a prompt appears) and
6
+ * `prompt_resolved` (it was answered — by this or a co-driving surface — and must dismiss). The answer travels
7
+ * back as `permission-response`/`ask-response`. This module is the pure list-transition + message-builder logic,
8
+ * consumed by `useWsSession` and shared unchanged by BOTH the WS and RTC session clients (same `TServerMessage`s).
9
+ */
10
+
11
+ import type { TActionResponse } from '@robota-sdk/agent-interface-transport';
12
+ import type {
13
+ IAskRequestEvent,
14
+ IPermissionRequestEvent,
15
+ TPermissionResultValue,
16
+ } from '@robota-sdk/agent-interface-session';
17
+ import type { TClientMessage, TServerMessage } from '@robota-sdk/agent-transport';
18
+
19
+ /** A prompt awaiting the owner's answer, rendered by the UI (built from the server event payloads). */
20
+ export type TPendingPrompt =
21
+ | ({ readonly kind: 'permission' } & IPermissionRequestEvent)
22
+ | ({ readonly kind: 'ask' } & IAskRequestEvent);
23
+
24
+ /**
25
+ * Fold a server message into the pending-prompt list: append on `permission_request`/`ask_request`, remove on
26
+ * `prompt_resolved`. Any other message returns the list unchanged (referential-equality preserved). Duplicate
27
+ * ids are not re-appended (idempotent against a resend).
28
+ */
29
+ export function applyPromptEvent(
30
+ prompts: readonly TPendingPrompt[],
31
+ msg: TServerMessage,
32
+ ): readonly TPendingPrompt[] {
33
+ switch (msg.type) {
34
+ case 'permission_request': {
35
+ // REMOTE-014 E5: keep requesterDriverId so the surface can show WHICH driver's turn raised the
36
+ // prompt. Issue #3288 §1: keep `requester` too, so it can show WHICH background agent did.
37
+ // #3288 §2: diffLines/diffFile (Edit/Write) and cwd (Shell, only when it differs) are the
38
+ // server's diff/command preview — carried through unchanged so the prompt can render them
39
+ // instead of raw args.
40
+ const { id, toolName, toolArgs, requesterDriverId, requester, diffLines, diffFile, cwd } =
41
+ msg.event;
42
+ if (prompts.some((p) => p.id === id)) return prompts;
43
+ return [
44
+ ...prompts,
45
+ {
46
+ kind: 'permission',
47
+ id,
48
+ toolName,
49
+ toolArgs,
50
+ ...(requesterDriverId ? { requesterDriverId } : {}),
51
+ ...(requester ? { requester } : {}),
52
+ ...(diffLines ? { diffLines, diffFile } : {}),
53
+ ...(cwd ? { cwd } : {}),
54
+ },
55
+ ];
56
+ }
57
+ case 'ask_request': {
58
+ const { id, request, requesterDriverId } = msg.event;
59
+ if (prompts.some((p) => p.id === id)) return prompts;
60
+ return [
61
+ ...prompts,
62
+ { kind: 'ask', id, request, ...(requesterDriverId ? { requesterDriverId } : {}) },
63
+ ];
64
+ }
65
+ case 'prompt_resolved': {
66
+ const { id } = msg.event;
67
+ const next = prompts.filter((p) => p.id !== id);
68
+ return next.length === prompts.length ? prompts : next;
69
+ }
70
+ default:
71
+ return prompts;
72
+ }
73
+ }
74
+
75
+ /** Build the client message that answers a permission prompt. */
76
+ export function permissionResponse(id: string, result: TPermissionResultValue): TClientMessage {
77
+ return { type: 'permission-response', id, result };
78
+ }
79
+
80
+ /** Build the client message that answers an ask prompt. */
81
+ export function askResponse(id: string, response: TActionResponse): TClientMessage {
82
+ return { type: 'ask-response', id, response };
83
+ }