@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.94

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 +409 -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,429 @@
1
+ import {
2
+ MoreHorizontal,
3
+ PanelLeftClose,
4
+ PanelLeftOpen,
5
+ Pencil,
6
+ Settings,
7
+ SquarePen,
8
+ Trash2,
9
+ } from 'lucide-react';
10
+ import { useEffect, useRef, useState } from 'react';
11
+
12
+ import { ConfirmDialog } from './Dialog.js';
13
+
14
+ import type { IWsSessionState } from '../hooks/useSessionClient.js';
15
+
16
+ type TListedSession = NonNullable<IWsSessionState['sessionListing']>['sessions'][number];
17
+
18
+ const MINUTE = 60_000;
19
+ const HOUR = 60 * MINUTE;
20
+ const DAY = 24 * HOUR;
21
+
22
+ /** "just now", "5m ago", "3h ago", "2d ago", then the date. */
23
+ export function formatUpdatedAt(updatedAt: string, now: number = Date.now()): string {
24
+ const at = Date.parse(updatedAt);
25
+ if (Number.isNaN(at)) return '';
26
+ const elapsed = Math.max(0, now - at);
27
+ if (elapsed < MINUTE) return 'just now';
28
+ if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
29
+ if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
30
+ if (elapsed < 7 * DAY) return `${Math.floor(elapsed / DAY)}d ago`;
31
+ return new Date(at).toISOString().slice(0, 10);
32
+ }
33
+
34
+ /**
35
+ * A session's title (#3289 §1): its name, else its stable first-message title, else "New session".
36
+ * Never `preview` — that field is the raw latest reply, which is what changes every turn and is what
37
+ * this title exists to stop showing.
38
+ */
39
+ export function sessionTitle(session: TListedSession): string {
40
+ const name = session.name?.trim();
41
+ if (name) return name;
42
+ const title = session.title?.trim();
43
+ if (title) return title;
44
+ return 'New session';
45
+ }
46
+
47
+ /**
48
+ * The clients on a row besides this surface: its own row counts this surface among them. Null when
49
+ * the host does not count clients, or no one else is there.
50
+ */
51
+ export function otherClients(session: TListedSession, isCurrent: boolean): number | null {
52
+ if (session.clients === undefined) return null;
53
+ const others = session.clients - (isCurrent ? 1 : 0);
54
+ return others >= 1 ? others : null;
55
+ }
56
+
57
+ /** Where a row's context menu opens: the point the mouse or the "More" button gave it. */
58
+ interface IMenuAnchor {
59
+ readonly sessionId: string;
60
+ readonly x: number;
61
+ readonly y: number;
62
+ }
63
+
64
+ /**
65
+ * #3189 — this workspace's sessions on the left of the conversation, as in Claude Code Desktop:
66
+ * start a new one, or click another to make it current. Rows the host could not read are listed,
67
+ * disabled, so a damaged session never looks deleted. A refused switch comes back as a notice.
68
+ * A host that keeps several sessions live marks the rows running now and counts who else is on them.
69
+ *
70
+ * #3289 §1 — each row also carries a "More" menu (or right-click) with Rename (inline) and Delete
71
+ * (confirmed). Rename on the current row reuses the `/rename` command, which also updates the live
72
+ * session's own name and title bar; rename on any other row writes the stored record directly.
73
+ */
74
+ export function SessionSidebar({
75
+ state,
76
+ brand,
77
+ className,
78
+ }: {
79
+ state: IWsSessionState;
80
+ /** The app's name, shown at the top of the sidebar while it is open. */
81
+ brand?: React.ReactNode;
82
+ className?: string;
83
+ }): React.ReactElement {
84
+ const listing = state.sessionListing ?? null;
85
+ const current = listing?.currentSessionId ?? null;
86
+ const unreadable = listing?.unreadableSessionIds ?? [];
87
+ const failed = state.sessionsError?.code === 'list_failed' ? state.sessionsError.message : null;
88
+ // #3282 §2 (part 2): session switching is disabled while disconnected, like the status chips and
89
+ // the `/` command menu — a switch could not reach the host either.
90
+ const connected = state.status === 'connected';
91
+
92
+ const [menu, setMenu] = useState<IMenuAnchor | null>(null);
93
+ const [renamingId, setRenamingId] = useState<string | null>(null);
94
+ const [renameValue, setRenameValue] = useState('');
95
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
96
+ const menuRef = useRef<HTMLDivElement | null>(null);
97
+ // Each row's own "More" button, kept live across re-renders so the delete dialog can send focus
98
+ // back to the exact control that opened it — even though the menu item that was actually clicked
99
+ // is gone by the time the dialog mounts (see `Dialog`'s `restoreFocusTo`).
100
+ const moreButtonRefs = useRef(new Map<string, HTMLButtonElement>());
101
+
102
+ // A menu closes on Escape or a click outside it — the usual context-menu contract.
103
+ useEffect(() => {
104
+ if (menu === null) return undefined;
105
+ const onKeyDown = (event: KeyboardEvent): void => {
106
+ if (event.key === 'Escape') setMenu(null);
107
+ };
108
+ const onPointerDown = (event: MouseEvent): void => {
109
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) setMenu(null);
110
+ };
111
+ document.addEventListener('keydown', onKeyDown);
112
+ document.addEventListener('mousedown', onPointerDown);
113
+ return () => {
114
+ document.removeEventListener('keydown', onKeyDown);
115
+ document.removeEventListener('mousedown', onPointerDown);
116
+ };
117
+ }, [menu]);
118
+
119
+ // A keyboard user who opened the menu lands on its first item, not nowhere.
120
+ useEffect(() => {
121
+ if (menu === null) return;
122
+ menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
123
+ }, [menu]);
124
+
125
+ const openMenuAt = (sessionId: string, x: number, y: number): void => setMenu({ sessionId, x, y });
126
+
127
+ const startRename = (session: TListedSession): void => {
128
+ setMenu(null);
129
+ setRenamingId(session.id);
130
+ setRenameValue(sessionTitle(session));
131
+ };
132
+
133
+ const saveRename = (session: TListedSession): void => {
134
+ const name = renameValue.trim();
135
+ setRenamingId(null);
136
+ if (name.length === 0 || name === sessionTitle(session)) return;
137
+ if (session.id === current) {
138
+ // The current session's own rename path: it also updates the live name and title bar.
139
+ state.send({ type: 'command', name: 'rename', args: name });
140
+ } else {
141
+ state.renameSessionInList?.(session.id, name);
142
+ }
143
+ };
144
+
145
+ const rows = listing?.sessions ?? [];
146
+ const confirmingSession = rows.find((row) => row.id === confirmDeleteId) ?? null;
147
+
148
+ return (
149
+ <aside
150
+ aria-label="Sessions"
151
+ className={`agent-ui flex w-[272px] flex-shrink-0 flex-col overflow-hidden bg-sidebar ${className ?? ''}`}
152
+ >
153
+ <div className="flex h-12 flex-shrink-0 items-center gap-2 px-4">
154
+ {brand}
155
+ <button
156
+ type="button"
157
+ aria-label="Hide sessions"
158
+ title="Hide sessions"
159
+ onClick={() => state.setSessionSidebarOpen?.(false)}
160
+ className="ml-auto rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground"
161
+ >
162
+ <PanelLeftClose size={17} strokeWidth={1.75} />
163
+ </button>
164
+ </div>
165
+
166
+ <div className="flex-shrink-0 px-2.5 pt-1">
167
+ <button
168
+ type="button"
169
+ disabled={!connected}
170
+ title={connected ? undefined : 'Reconnecting…'}
171
+ onClick={() => state.newSession?.()}
172
+ className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium text-foreground hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent"
173
+ >
174
+ <SquarePen size={16} strokeWidth={1.75} className="text-muted-foreground" />
175
+ New session
176
+ </button>
177
+ </div>
178
+
179
+ <div className="flex-1 overflow-y-auto px-2.5 pb-3 pt-4">
180
+ <h2 className="px-2.5 pb-1.5 text-[12.5px] font-medium text-subtle">Sessions</h2>
181
+ {failed !== null ? (
182
+ <p className="px-2.5 py-1 text-[13px] leading-snug text-destructive">{failed}</p>
183
+ ) : null}
184
+ {listing === null && failed === null ? (
185
+ <p className="px-2.5 py-1 text-[13px] text-subtle">Loading sessions…</p>
186
+ ) : null}
187
+ <ul className="space-y-px">
188
+ {rows.map((session) => {
189
+ const isCurrent = session.id === current;
190
+ const others = otherClients(session, isCurrent);
191
+ const isRenaming = renamingId === session.id;
192
+ // #3289 §3 review: the explicit `aria-label` below replaces name-from-content outright, so
193
+ // the live dot's and "N other(s)" span's own text no longer reaches the accessible name —
194
+ // folded back in here as a description instead, alongside the relative time.
195
+ const statusParts = [
196
+ session.live === true ? 'Live' : null,
197
+ others !== null ? `${others} ${others === 1 ? 'other' : 'others'}` : null,
198
+ ].filter((part): part is string => part !== null);
199
+ const statusId = `session-status-${session.id}`;
200
+ const updatedId = `session-updated-${session.id}`;
201
+ const describedBy =
202
+ statusParts.length > 0 ? `${updatedId} ${statusId}` : updatedId;
203
+ return (
204
+ <li key={session.id} className="group relative">
205
+ {isRenaming ? (
206
+ <input
207
+ autoFocus
208
+ aria-label={`Rename ${sessionTitle(session)}`}
209
+ value={renameValue}
210
+ onChange={(event) => setRenameValue(event.target.value)}
211
+ onBlur={() => saveRename(session)}
212
+ onKeyDown={(event) => {
213
+ if (event.key === 'Enter') {
214
+ event.preventDefault();
215
+ saveRename(session);
216
+ } else if (event.key === 'Escape') {
217
+ event.preventDefault();
218
+ setRenamingId(null);
219
+ }
220
+ }}
221
+ className="w-full rounded-lg border border-accent bg-raised px-2.5 py-2 text-[14px] text-foreground outline-none"
222
+ />
223
+ ) : (
224
+ <button
225
+ type="button"
226
+ aria-current={isCurrent ? 'true' : undefined}
227
+ // #3289 §3: an explicit name + description — the row's own visible text has no
228
+ // whitespace between its parts, so an unlabelled button reads as one run-together
229
+ // string ("Title validation fixjust now") to a screen reader.
230
+ aria-label={sessionTitle(session)}
231
+ aria-describedby={describedBy}
232
+ // #3282 §2 (part 2): while disconnected, the tooltip says why instead of previewing.
233
+ title={connected ? session.preview || session.id : 'Reconnecting…'}
234
+ disabled={!connected}
235
+ onClick={() => {
236
+ if (!isCurrent) state.switchSession?.(session.id);
237
+ }}
238
+ onContextMenu={(event) => {
239
+ event.preventDefault();
240
+ openMenuAt(session.id, event.clientX, event.clientY);
241
+ }}
242
+ className={`w-full rounded-lg py-2 pl-2.5 pr-8 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
243
+ isCurrent ? 'bg-raised' : 'hover:bg-hover'
244
+ }`}
245
+ >
246
+ <span
247
+ className={`block truncate text-[14px] leading-snug ${
248
+ isCurrent ? 'font-medium text-foreground' : 'text-foreground/85'
249
+ }`}
250
+ >
251
+ {sessionTitle(session)}
252
+ </span>
253
+ <span className="mt-0.5 flex items-center gap-1.5 text-[12.5px] tabular-nums text-subtle">
254
+ {session.live === true ? (
255
+ <span
256
+ title="Live in the host"
257
+ className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
258
+ >
259
+ <span className="sr-only">live</span>
260
+ </span>
261
+ ) : null}
262
+ <span id={updatedId}>{formatUpdatedAt(session.updatedAt)}</span>
263
+ {others !== null ? (
264
+ <>
265
+ <span aria-hidden="true">·</span>
266
+ <span className="text-muted-foreground">
267
+ {others} {others === 1 ? 'other' : 'others'}
268
+ </span>
269
+ </>
270
+ ) : null}
271
+ {statusParts.length > 0 ? (
272
+ <span id={statusId} className="sr-only">
273
+ {statusParts.join(', ')}
274
+ </span>
275
+ ) : null}
276
+ </span>
277
+ </button>
278
+ )}
279
+ {!isRenaming ? (
280
+ <button
281
+ type="button"
282
+ ref={(el) => {
283
+ if (el) moreButtonRefs.current.set(session.id, el);
284
+ else moreButtonRefs.current.delete(session.id);
285
+ }}
286
+ aria-label={`More for ${sessionTitle(session)}`}
287
+ aria-haspopup="menu"
288
+ aria-expanded={menu?.sessionId === session.id}
289
+ onClick={(event) => {
290
+ const rect = event.currentTarget.getBoundingClientRect();
291
+ openMenuAt(session.id, rect.left, rect.bottom);
292
+ }}
293
+ className="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground opacity-0 hover:bg-hover hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
294
+ >
295
+ <MoreHorizontal size={15} strokeWidth={1.75} />
296
+ </button>
297
+ ) : null}
298
+ </li>
299
+ );
300
+ })}
301
+ {unreadable.length > 0 && (
302
+ // Listed, never dropped, so an unreadable session does not look deleted — but as one
303
+ // folded line: a store can hold many old records, and each is nothing to click.
304
+ <li>
305
+ <details className="px-2.5 py-2 text-[12.5px] text-subtle">
306
+ <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
307
+ {unreadable.length} older {unreadable.length === 1 ? 'session' : 'sessions'} in
308
+ this folder can&apos;t be opened
309
+ </summary>
310
+ <ul className="mt-1.5 space-y-0.5 font-mono text-[11.5px]">
311
+ {unreadable.map((id) => (
312
+ <li key={id} className="truncate">
313
+ {id}
314
+ </li>
315
+ ))}
316
+ </ul>
317
+ </details>
318
+ </li>
319
+ )}
320
+ </ul>
321
+ </div>
322
+
323
+ <div className="flex-shrink-0 border-t border-border/60 px-2.5 py-2">
324
+ <button
325
+ type="button"
326
+ aria-label="Settings"
327
+ title="Settings"
328
+ onClick={() => state.openSettings()}
329
+ className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13.5px] text-muted-foreground hover:bg-hover hover:text-foreground"
330
+ >
331
+ <Settings size={16} strokeWidth={1.75} />
332
+ Settings
333
+ </button>
334
+ </div>
335
+
336
+ {menu !== null
337
+ ? (() => {
338
+ const session = rows.find((row) => row.id === menu.sessionId);
339
+ if (!session) return null;
340
+ return (
341
+ <div
342
+ ref={menuRef}
343
+ role="menu"
344
+ aria-label={`Actions for ${sessionTitle(session)}`}
345
+ style={{ position: 'fixed', left: menu.x, top: menu.y }}
346
+ className="gui-rise z-40 min-w-[140px] overflow-hidden rounded-lg bg-popover py-1 text-popover-foreground shadow-xl shadow-black/30"
347
+ >
348
+ <button
349
+ type="button"
350
+ role="menuitem"
351
+ onClick={() => startRename(session)}
352
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13.5px] hover:bg-hover"
353
+ >
354
+ <Pencil size={14} strokeWidth={1.75} />
355
+ Rename
356
+ </button>
357
+ <button
358
+ type="button"
359
+ role="menuitem"
360
+ onClick={() => {
361
+ setMenu(null);
362
+ setConfirmDeleteId(session.id);
363
+ }}
364
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13.5px] text-destructive hover:bg-hover"
365
+ >
366
+ <Trash2 size={14} strokeWidth={1.75} />
367
+ Delete…
368
+ </button>
369
+ </div>
370
+ );
371
+ })()
372
+ : null}
373
+
374
+ {confirmingSession !== null ? (
375
+ <ConfirmDialog
376
+ open
377
+ title={`Delete "${sessionTitle(confirmingSession)}"?`}
378
+ body="This removes its conversation from this computer."
379
+ confirmLabel="Delete"
380
+ destructive
381
+ restoreFocusTo={moreButtonRefs.current.get(confirmingSession.id) ?? null}
382
+ onCancel={() => setConfirmDeleteId(null)}
383
+ onConfirm={() => {
384
+ state.deleteSession?.(confirmingSession.id);
385
+ setConfirmDeleteId(null);
386
+ }}
387
+ />
388
+ ) : null}
389
+ </aside>
390
+ );
391
+ }
392
+
393
+ /** The collapsed sidebar: a narrow rail that opens it again. */
394
+ export function SessionSidebarRail({ state }: { state: IWsSessionState }): React.ReactElement {
395
+ // #3282 §2 (part 2): starting a session is disabled while disconnected, matching the open sidebar.
396
+ const connected = state.status === 'connected';
397
+ return (
398
+ <div className="flex w-12 flex-shrink-0 flex-col items-center gap-1 bg-sidebar py-2.5">
399
+ <button
400
+ type="button"
401
+ aria-label="Show sessions"
402
+ title="Show sessions"
403
+ onClick={() => state.setSessionSidebarOpen?.(true)}
404
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground"
405
+ >
406
+ <PanelLeftOpen size={17} strokeWidth={1.75} />
407
+ </button>
408
+ <button
409
+ type="button"
410
+ aria-label="New session"
411
+ title={connected ? 'New session' : 'Reconnecting…'}
412
+ disabled={!connected}
413
+ onClick={() => state.newSession?.()}
414
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent"
415
+ >
416
+ <SquarePen size={16} strokeWidth={1.75} />
417
+ </button>
418
+ <button
419
+ type="button"
420
+ aria-label="Settings"
421
+ title="Settings"
422
+ onClick={() => state.openSettings()}
423
+ className="mt-auto rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground"
424
+ >
425
+ <Settings size={16} strokeWidth={1.75} />
426
+ </button>
427
+ </div>
428
+ );
429
+ }