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

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 (81) hide show
  1. package/CHANGELOG.md +548 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +736 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +8525 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +8575 -0
  9. package/dist/node/index.d.cts +736 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +736 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +8525 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +130 -0
  17. package/package.json +99 -3
  18. package/src/app-view/bridge.ts +217 -0
  19. package/src/app-view/call-tool-result.ts +58 -0
  20. package/src/app-view/host-context.ts +37 -0
  21. package/src/app-view/types.ts +12 -0
  22. package/src/client/runtime-host.ts +84 -0
  23. package/src/client/ws-session-client.ts +131 -0
  24. package/src/clipboard.ts +81 -0
  25. package/src/components/AgentActivityPanel.tsx +371 -0
  26. package/src/components/AgentSwitcherSheet.tsx +93 -0
  27. package/src/components/AppView.tsx +147 -0
  28. package/src/components/Brand.tsx +33 -0
  29. package/src/components/Composer.tsx +685 -0
  30. package/src/components/ConversationView.tsx +789 -0
  31. package/src/components/Dialog.tsx +230 -0
  32. package/src/components/DiffLines.tsx +81 -0
  33. package/src/components/ExecutionDetailSheet.tsx +142 -0
  34. package/src/components/HelpSheet.tsx +104 -0
  35. package/src/components/PermissionPrompt.tsx +762 -0
  36. package/src/components/PersonalUsageDashboard.tsx +39 -0
  37. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  38. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  39. package/src/components/PopupMenu.tsx +166 -0
  40. package/src/components/ProjectPanel.tsx +268 -0
  41. package/src/components/SessionMonitor.tsx +180 -0
  42. package/src/components/SessionSidebar.tsx +429 -0
  43. package/src/components/SessionSurface.tsx +482 -0
  44. package/src/components/SessionSurfaceChrome.tsx +319 -0
  45. package/src/components/SettingsFields.tsx +159 -0
  46. package/src/components/SettingsGeneralSection.tsx +49 -0
  47. package/src/components/SettingsMcpSection.tsx +157 -0
  48. package/src/components/SettingsPermissionsSection.tsx +137 -0
  49. package/src/components/SettingsPluginsSection.tsx +165 -0
  50. package/src/components/SettingsProvidersSection.tsx +211 -0
  51. package/src/components/SettingsScreen.tsx +340 -0
  52. package/src/components/StatusControls.tsx +294 -0
  53. package/src/components/composer-attachments.ts +246 -0
  54. package/src/components/personal-usage-dashboard-types.ts +26 -0
  55. package/src/driver-labels.ts +88 -0
  56. package/src/execution-entry-status.ts +55 -0
  57. package/src/goal-view.ts +19 -0
  58. package/src/hooks/command-menu.ts +65 -0
  59. package/src/hooks/excluded-commands.ts +52 -0
  60. package/src/hooks/prompt-state.ts +83 -0
  61. package/src/hooks/server-message-handling.ts +95 -0
  62. package/src/hooks/session-client-types.ts +344 -0
  63. package/src/hooks/ui-intent-state.ts +74 -0
  64. package/src/hooks/use-agent-switcher-state.ts +125 -0
  65. package/src/hooks/use-app-view-requests.ts +178 -0
  66. package/src/hooks/use-execution-detail.ts +118 -0
  67. package/src/hooks/use-media-query.ts +27 -0
  68. package/src/hooks/use-model-list.ts +45 -0
  69. package/src/hooks/use-personal-usage.ts +138 -0
  70. package/src/hooks/use-project-panel.ts +117 -0
  71. package/src/hooks/use-schedules-state.ts +92 -0
  72. package/src/hooks/use-session-directory.ts +202 -0
  73. package/src/hooks/use-settings-state.ts +115 -0
  74. package/src/hooks/useSessionClient.ts +1051 -0
  75. package/src/index.ts +72 -0
  76. package/src/product-identity.tsx +26 -0
  77. package/src/schedule-view.ts +92 -0
  78. package/src/styles/surface.css +231 -0
  79. package/src/styles/surface.css.d.mts +1 -0
  80. package/src/styles/theme.css +38 -0
  81. package/src/styles/theme.css.d.mts +1 -0
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import { Check } from 'lucide-react';
4
+ import { useEffect, useId, useRef, useState } from 'react';
5
+
6
+ /** One choice in a `PopupMenu`. `kind: 'radio'` (the default) is a value the person picks between,
7
+ * shown with a checkmark when it is the current one; `kind: 'action'` runs something instead (e.g.
8
+ * "Manage providers…") and never carries a checked state. */
9
+ export interface IPopupMenuItem {
10
+ readonly key: string;
11
+ readonly label: string;
12
+ readonly description?: string;
13
+ readonly kind?: 'radio' | 'action';
14
+ readonly checked?: boolean;
15
+ readonly destructive?: boolean;
16
+ readonly onSelect: () => void;
17
+ }
18
+
19
+ export interface IPopupMenuSection {
20
+ readonly heading?: string;
21
+ readonly items: readonly IPopupMenuItem[];
22
+ }
23
+
24
+ /**
25
+ * A HIG pop-up menu (#3282 §2): `role="menu"`, its choices as `menuitemradio` with `aria-checked`
26
+ * (or `menuitem` for a plain action, e.g. "Manage providers…"), grouped into optional labelled
27
+ * sections. Arrow keys move a roving focus, Enter/Space activates the focused item, and Esc — or a
28
+ * click outside — closes the menu and returns focus to the trigger that opened it, so the person
29
+ * never loses their place. Initial focus lands on the current (checked) item, matching a native
30
+ * pop-up menu; nothing else is pre-highlighted beyond that same focus ring.
31
+ */
32
+ export function PopupMenu({
33
+ label,
34
+ sections,
35
+ onClose,
36
+ triggerRef,
37
+ }: {
38
+ label: string;
39
+ sections: readonly IPopupMenuSection[];
40
+ onClose: () => void;
41
+ triggerRef: React.RefObject<HTMLElement | null>;
42
+ }): React.ReactElement {
43
+ const items = sections.flatMap((section) => section.items);
44
+ const idPrefix = useId();
45
+ const containerRef = useRef<HTMLDivElement>(null);
46
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
47
+ const [focusedIndex, setFocusedIndex] = useState(() => {
48
+ const current = items.findIndex((item) => item.checked);
49
+ return current >= 0 ? current : 0;
50
+ });
51
+
52
+ const close = (returnFocus: boolean): void => {
53
+ onClose();
54
+ if (returnFocus) triggerRef.current?.focus();
55
+ };
56
+
57
+ useEffect(() => {
58
+ itemRefs.current[focusedIndex]?.focus();
59
+ }, [focusedIndex]);
60
+
61
+ useEffect(() => {
62
+ const onPointerDown = (event: MouseEvent): void => {
63
+ const target = event.target as Node;
64
+ if (containerRef.current?.contains(target)) return;
65
+ if (triggerRef.current?.contains(target)) return;
66
+ close(false);
67
+ };
68
+ document.addEventListener('mousedown', onPointerDown);
69
+ return () => document.removeEventListener('mousedown', onPointerDown);
70
+ // Registered once (empty deps): `onClose`/`triggerRef` are stable for the life of an open menu —
71
+ // it is remounted fresh (`{open ? <PopupMenu .../> : null}`) rather than kept open across a prop
72
+ // change that would replace them.
73
+ }, []);
74
+
75
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
76
+ if (event.key === 'Escape') {
77
+ event.preventDefault();
78
+ close(true);
79
+ return;
80
+ }
81
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
82
+ event.preventDefault();
83
+ if (items.length === 0) return;
84
+ const step = event.key === 'ArrowDown' ? 1 : -1;
85
+ setFocusedIndex((index) => (index + step + items.length) % items.length);
86
+ return;
87
+ }
88
+ if (event.key === 'Enter' || event.key === ' ') {
89
+ event.preventDefault();
90
+ const item = items[focusedIndex];
91
+ if (item) {
92
+ item.onSelect();
93
+ close(true);
94
+ }
95
+ }
96
+ };
97
+
98
+ let cursor = -1;
99
+ return (
100
+ <div
101
+ ref={containerRef}
102
+ role="menu"
103
+ aria-label={label}
104
+ onKeyDown={onKeyDown}
105
+ className="gui-rise absolute bottom-full z-30 mb-2 max-h-[360px] w-64 overflow-y-auto rounded-2xl bg-popover p-1.5 shadow-2xl shadow-black/35 focus:outline-none"
106
+ >
107
+ {sections.map((section, sectionIndex) => (
108
+ <div key={section.heading ?? sectionIndex} role="group" aria-label={section.heading}>
109
+ {section.heading ? (
110
+ <div
111
+ role="presentation"
112
+ className="px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-subtle"
113
+ >
114
+ {section.heading}
115
+ </div>
116
+ ) : null}
117
+ {section.items.map((item) => {
118
+ cursor += 1;
119
+ const index = cursor;
120
+ const isAction = item.kind === 'action';
121
+ // The accessible NAME is the label alone (`aria-label`) — otherwise a screen reader would
122
+ // read the description as part of it (button content is used as the name by default). The
123
+ // description is still announced, as a `aria-describedby` description instead.
124
+ const descId = item.description ? `${idPrefix}-desc-${item.key}` : undefined;
125
+ return (
126
+ <button
127
+ key={item.key}
128
+ ref={(node) => {
129
+ itemRefs.current[index] = node;
130
+ }}
131
+ type="button"
132
+ role={isAction ? 'menuitem' : 'menuitemradio'}
133
+ aria-checked={isAction ? undefined : item.checked === true}
134
+ aria-label={item.label}
135
+ aria-describedby={descId}
136
+ tabIndex={index === focusedIndex ? 0 : -1}
137
+ onFocus={() => setFocusedIndex(index)}
138
+ onClick={() => {
139
+ item.onSelect();
140
+ close(true);
141
+ }}
142
+ className={`flex w-full items-start gap-2 rounded-xl px-3 py-2 text-left text-[13.5px] ${
143
+ item.destructive ? 'text-destructive hover:bg-destructive/10' : 'text-foreground hover:bg-hover'
144
+ } ${index === focusedIndex ? 'bg-hover' : ''}`}
145
+ >
146
+ {isAction ? null : (
147
+ <span className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center">
148
+ {item.checked ? <Check size={14} strokeWidth={2.25} aria-hidden="true" /> : null}
149
+ </span>
150
+ )}
151
+ <span className="min-w-0 flex-1">
152
+ <span className="block truncate">{item.label}</span>
153
+ {item.description ? (
154
+ <span id={descId} className="block truncate text-[12px] text-muted-foreground">
155
+ {item.description}
156
+ </span>
157
+ ) : null}
158
+ </span>
159
+ </button>
160
+ );
161
+ })}
162
+ </div>
163
+ ))}
164
+ </div>
165
+ );
166
+ }
@@ -0,0 +1,268 @@
1
+ 'use client';
2
+
3
+ import { ChevronRight, ExternalLink, GitBranch, LoaderCircle, RefreshCw } from 'lucide-react';
4
+ import React, { useEffect, useState } from 'react';
5
+
6
+ import { DiffLines } from './DiffLines.js';
7
+
8
+ import type {
9
+ IWsSessionState,
10
+ TProjectDiffRead,
11
+ TProjectMemoryRead,
12
+ TProjectStatusRead,
13
+ } from '../hooks/session-client-types.js';
14
+
15
+ /**
16
+ * #3282 §4c — the Project panel: git status (Changes), one file's diff on click, and project memory as
17
+ * readable text. The owner's design principle 4 (#3277): everything the host implements is usable from
18
+ * the GUI through organized screens — this is git status/diff and project memory's screen. Checkpoints
19
+ * (rewind) are deliberately absent (see `agent-gui-web/docs/SPEC.md`'s design decisions): edit
20
+ * checkpoints only work on a host that can prove a write stays inside the project
21
+ * (`supportsWorkspaceProjectMutation`, Linux-only by design, not a switched-off setting), so there is
22
+ * nothing safe to show here on macOS or Windows.
23
+ */
24
+
25
+ type TProjectStatusFile = Extract<TProjectStatusRead, { kind: 'status' }>['files'][number];
26
+
27
+ const STATUS_TONE: Readonly<Record<TProjectStatusFile['status'], string>> = {
28
+ Added: 'text-accent',
29
+ Modified: 'text-warning',
30
+ Deleted: 'text-destructive',
31
+ Renamed: 'text-muted-foreground',
32
+ Copied: 'text-muted-foreground',
33
+ Untracked: 'text-subtle',
34
+ Conflicted: 'text-destructive',
35
+ };
36
+
37
+ function StatusRow({
38
+ file,
39
+ open,
40
+ onOpen,
41
+ }: {
42
+ file: TProjectStatusFile;
43
+ open: boolean;
44
+ onOpen: () => void;
45
+ }): React.ReactElement {
46
+ return (
47
+ <button
48
+ type="button"
49
+ aria-expanded={open}
50
+ onClick={onOpen}
51
+ className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1.5 text-left hover:bg-hover"
52
+ >
53
+ <ChevronRight
54
+ size={13}
55
+ className={`flex-shrink-0 text-subtle transition-transform ${open ? 'rotate-90' : ''}`}
56
+ />
57
+ <span
58
+ className={`w-[76px] flex-shrink-0 text-[12px] font-medium ${STATUS_TONE[file.status]}`}
59
+ >
60
+ {file.status}
61
+ </span>
62
+ <span className="min-w-0 flex-1 truncate break-all font-mono text-[12.5px] text-foreground" title={file.path}>
63
+ {file.path}
64
+ </span>
65
+ {typeof file.added === 'number' && (
66
+ <span className="flex-shrink-0 font-mono text-[12px] text-accent">+{file.added}</span>
67
+ )}
68
+ {typeof file.removed === 'number' && (
69
+ <span className="flex-shrink-0 font-mono text-[12px] text-destructive">-{file.removed}</span>
70
+ )}
71
+ </button>
72
+ );
73
+ }
74
+
75
+ function ChangesSection({
76
+ status,
77
+ statusState,
78
+ onRefresh,
79
+ openPath,
80
+ onSelectFile,
81
+ diff,
82
+ diffState,
83
+ diffPath,
84
+ }: {
85
+ status: TProjectStatusRead | null;
86
+ statusState: IWsSessionState['projectStatusState'];
87
+ onRefresh: () => void;
88
+ openPath: string | null;
89
+ onSelectFile: (path: string) => void;
90
+ diff: TProjectDiffRead | null;
91
+ diffState: IWsSessionState['projectDiffState'];
92
+ diffPath: string | null;
93
+ }): React.ReactElement {
94
+ return (
95
+ <div className="flex flex-col gap-1.5 rounded-xl bg-card px-2 py-2">
96
+ <div className="flex items-center gap-2 px-1">
97
+ <p className="flex-1 text-[12px] font-medium text-muted-foreground">
98
+ {status?.kind === 'status' && status.branch ? `Changes — ${status.branch}` : 'Changes'}
99
+ </p>
100
+ <button
101
+ type="button"
102
+ onClick={onRefresh}
103
+ disabled={statusState === 'loading'}
104
+ aria-label="Refresh"
105
+ className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[12px] text-subtle hover:bg-hover hover:text-foreground disabled:opacity-60"
106
+ >
107
+ <RefreshCw size={12} className={statusState === 'loading' ? 'animate-spin' : ''} />
108
+ Refresh
109
+ </button>
110
+ </div>
111
+
112
+ {statusState === 'loading' && status === null && (
113
+ <p className="px-1 py-2 text-[13px] text-muted-foreground">Reading git status…</p>
114
+ )}
115
+ {statusState === 'error' && (
116
+ <p className="px-1 py-2 text-[13px] text-destructive">Could not read git status.</p>
117
+ )}
118
+ {status?.kind === 'not-a-repository' && (
119
+ <p className="px-1 py-2 text-[13px] text-muted-foreground">This folder is not a git repository.</p>
120
+ )}
121
+ {status?.kind === 'failed' && <p className="px-1 py-2 text-[13px] text-destructive">{status.message}</p>}
122
+ {status?.kind === 'status' && status.files.length === 0 && (
123
+ <p className="px-1 py-2 text-[13px] text-muted-foreground">No changes.</p>
124
+ )}
125
+ {status?.kind === 'status' &&
126
+ status.files.map((file) => (
127
+ <div key={file.path} className="flex flex-col">
128
+ <StatusRow file={file} open={openPath === file.path} onOpen={() => onSelectFile(file.path)} />
129
+ {openPath === file.path && (
130
+ <div className="pl-6">
131
+ {diffState === 'loading' && diffPath === file.path && (
132
+ <p className="py-1.5 text-[12.5px] text-muted-foreground">Reading diff…</p>
133
+ )}
134
+ {diff && diffPath === file.path && diff.kind === 'diff' && diff.diffLines.length === 0 && (
135
+ <p className="py-1.5 text-[12.5px] text-muted-foreground">No differences.</p>
136
+ )}
137
+ {diff && diffPath === file.path && diff.kind === 'diff' && diff.diffLines.length > 0 && (
138
+ <>
139
+ <DiffLines diffLines={diff.diffLines} />
140
+ {diff.truncated && (
141
+ <p className="py-1 text-[12px] text-subtle">The diff was too long and was cut short.</p>
142
+ )}
143
+ </>
144
+ )}
145
+ {diff && diffPath === file.path && diff.kind === 'failed' && (
146
+ <p className="py-1.5 text-[12.5px] text-destructive">{diff.message}</p>
147
+ )}
148
+ {diff && diffPath === file.path && diff.kind === 'outside-workspace' && (
149
+ <p className="py-1.5 text-[12.5px] text-destructive">
150
+ That path is outside the workspace.
151
+ </p>
152
+ )}
153
+ {diff && diffPath === file.path && diff.kind === 'not-a-repository' && (
154
+ <p className="py-1.5 text-[12.5px] text-muted-foreground">
155
+ This folder is not a git repository.
156
+ </p>
157
+ )}
158
+ </div>
159
+ )}
160
+ </div>
161
+ ))}
162
+ {status?.kind === 'status' && status.truncated && (
163
+ <p className="px-1 pt-1 text-[12px] text-subtle">Showing the first files only — there are more.</p>
164
+ )}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function MemorySection({
170
+ memory,
171
+ memoryState,
172
+ onOpenInEditor,
173
+ }: {
174
+ memory: TProjectMemoryRead | null;
175
+ memoryState: IWsSessionState['projectMemoryState'];
176
+ onOpenInEditor?: (path: string) => void;
177
+ }): React.ReactElement {
178
+ return (
179
+ <div className="flex flex-col gap-1.5 rounded-xl bg-card px-2 py-2">
180
+ <div className="flex items-center gap-2 px-1">
181
+ <p className="flex-1 text-[12px] font-medium text-muted-foreground">Memory</p>
182
+ {memory?.kind === 'memory' && onOpenInEditor && (
183
+ <button
184
+ type="button"
185
+ onClick={() => onOpenInEditor(memory.path)}
186
+ className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[12px] text-subtle hover:bg-hover hover:text-foreground"
187
+ >
188
+ <ExternalLink size={12} />
189
+ Open in editor
190
+ </button>
191
+ )}
192
+ </div>
193
+ {memoryState === 'loading' && memory === null && (
194
+ <p className="px-1 py-1 text-[13px] text-muted-foreground">Reading project memory…</p>
195
+ )}
196
+ {memory?.kind === 'unavailable' && (
197
+ <p className="px-1 py-1 text-[13px] text-muted-foreground">{memory.message}</p>
198
+ )}
199
+ {memory?.kind === 'memory' && (
200
+ <>
201
+ <pre className="max-h-64 overflow-y-auto whitespace-pre-wrap break-words rounded-lg bg-sidebar px-3 py-2 font-mono text-[12.5px] leading-relaxed text-foreground">
202
+ {memory.content.length > 0 ? memory.content : '(empty)'}
203
+ </pre>
204
+ {memory.truncated && (
205
+ <p className="px-1 pt-1 text-[12px] text-subtle">The memory was too long and was cut short.</p>
206
+ )}
207
+ </>
208
+ )}
209
+ </div>
210
+ );
211
+ }
212
+
213
+ export function ProjectPanel({
214
+ state,
215
+ className,
216
+ onOpenMemoryInEditor,
217
+ }: {
218
+ state: IWsSessionState;
219
+ className?: string;
220
+ /** Present only when the host can open a file in an external editor (the desktop app). */
221
+ onOpenMemoryInEditor?: (path: string) => void;
222
+ }): React.ReactElement {
223
+ useEffect(() => {
224
+ state.requestProjectStatus();
225
+ state.requestProjectMemory();
226
+ }, [state.requestProjectStatus, state.requestProjectMemory]);
227
+
228
+ // Which row is expanded — local, and independent from `projectDiffPath` (what the LAST diff
229
+ // request/response concerns): closing a row never needs a request, and reopening a different one
230
+ // must not show the previous file's diff while its own is still loading.
231
+ const [openPath, setOpenPath] = useState<string | null>(null);
232
+
233
+ return (
234
+ <div className={`agent-ui flex flex-col overflow-hidden ${className ?? ''}`}>
235
+ <div className="flex h-12 flex-shrink-0 items-center gap-2 px-4">
236
+ <GitBranch size={15} className="text-subtle" />
237
+ <span className="text-[14px] font-medium text-foreground">Project</span>
238
+ {state.projectStatusState === 'loading' && (
239
+ <LoaderCircle size={13} className="ml-auto animate-spin text-subtle" aria-label="loading" />
240
+ )}
241
+ </div>
242
+ <div className="flex-1 space-y-2 overflow-y-auto px-3 pb-3">
243
+ <ChangesSection
244
+ status={state.projectStatus}
245
+ statusState={state.projectStatusState}
246
+ onRefresh={() => state.requestProjectStatus()}
247
+ openPath={openPath}
248
+ onSelectFile={(path) => {
249
+ if (openPath === path) {
250
+ setOpenPath(null);
251
+ return;
252
+ }
253
+ setOpenPath(path);
254
+ state.requestProjectDiff(path);
255
+ }}
256
+ diff={state.projectDiffPath === openPath ? state.projectDiff : null}
257
+ diffState={state.projectDiffPath === openPath ? state.projectDiffState : 'loading'}
258
+ diffPath={state.projectDiffPath}
259
+ />
260
+ <MemorySection
261
+ memory={state.projectMemory}
262
+ memoryState={state.projectMemoryState}
263
+ onOpenInEditor={onOpenMemoryInEditor}
264
+ />
265
+ </div>
266
+ </div>
267
+ );
268
+ }
@@ -0,0 +1,180 @@
1
+ 'use client';
2
+
3
+ import { useProductIdentity } from '../product-identity.js';
4
+
5
+ import React, { useState } from 'react';
6
+
7
+ import { useWsSession } from '../hooks/useSessionClient.js';
8
+
9
+ import { AgentActivityPanel } from './AgentActivityPanel.js';
10
+ import { ConversationView } from './ConversationView.js';
11
+
12
+ const STATUS_CONFIG: Record<string, { label: string; dot: string; text: string; glow: string }> = {
13
+ connected: {
14
+ label: 'Connected',
15
+ dot: 'bg-accent',
16
+ text: 'text-muted-foreground',
17
+ glow: 'status-glow',
18
+ },
19
+ connecting: {
20
+ label: 'Connecting…',
21
+ dot: 'bg-warning animate-pulse',
22
+ text: 'text-warning',
23
+ glow: '',
24
+ },
25
+ disconnected: { label: 'Disconnected', dot: 'bg-subtle', text: 'text-subtle', glow: '' },
26
+ error: { label: 'Error', dot: 'bg-destructive', text: 'text-destructive', glow: '' },
27
+ };
28
+
29
+ interface ISessionMonitorProps {
30
+ /** WebSocket URL. Injected by the HTTP server via <meta name="ws-url">. */
31
+ wsUrl: string;
32
+ className?: string;
33
+ }
34
+
35
+ export function SessionMonitor({ wsUrl, className }: ISessionMonitorProps): React.ReactElement {
36
+ const { identity } = useProductIdentity();
37
+ const [url, setUrl] = useState(wsUrl);
38
+ const [inputUrl, setInputUrl] = useState(wsUrl);
39
+
40
+ const {
41
+ status,
42
+ messages,
43
+ activeTools,
44
+ streamingText,
45
+ isThinking,
46
+ executionWorkspace,
47
+ ownDriverId,
48
+ send,
49
+ } = useWsSession(url);
50
+ const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.disconnected;
51
+
52
+ const backgroundTasks =
53
+ executionWorkspace?.entries.filter(
54
+ (e) => e.kind === 'background_task' && e.visibility !== 'collapsed',
55
+ ) ?? [];
56
+ const hasAgents = backgroundTasks.length > 0;
57
+
58
+ return (
59
+ <div className={`agent-ui flex flex-col h-full overflow-hidden bg-background ${className ?? ''}`}>
60
+ {/* Header */}
61
+ <div className="flex h-12 flex-shrink-0 items-center gap-3 px-5">
62
+ <div className="flex items-center gap-2.5">
63
+ <span className={`h-2 w-2 rounded-full flex-shrink-0 ${cfg.dot} ${cfg.glow}`} />
64
+ <span className="text-[15px] font-semibold text-foreground">CLI monitor</span>
65
+ <span role="status" className={`text-[13px] ${cfg.text}`}>
66
+ {cfg.label}
67
+ </span>
68
+ </div>
69
+ <div className="ml-auto flex items-center gap-2">
70
+ <input
71
+ aria-label="WebSocket URL"
72
+ className="h-8 w-60 rounded-lg bg-raised px-3 font-mono text-[12.5px] text-foreground placeholder:text-subtle focus:outline-none"
73
+ value={inputUrl}
74
+ onChange={(e) => setInputUrl(e.target.value)}
75
+ onKeyDown={(e) => {
76
+ if (e.key === 'Enter') setUrl(inputUrl);
77
+ }}
78
+ placeholder="ws://localhost:7070"
79
+ />
80
+ <button
81
+ className="h-8 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90"
82
+ onClick={() => setUrl(inputUrl)}
83
+ >
84
+ Connect
85
+ </button>
86
+ </div>
87
+ </div>
88
+
89
+ {/* Main content: conversation + optional agent panel */}
90
+ <div className="flex flex-1 overflow-hidden">
91
+ {/* Left: conversation */}
92
+ <div
93
+ className={`flex flex-col overflow-hidden min-w-0 ${hasAgents ? 'flex-[2]' : 'flex-1'}`}
94
+ >
95
+ <div className="flex-1 overflow-hidden">
96
+ {status === 'connected' ? (
97
+ <ConversationView
98
+ messages={messages}
99
+ activeTools={activeTools}
100
+ streamingText={streamingText}
101
+ isThinking={isThinking}
102
+ ownDriverId={ownDriverId}
103
+ />
104
+ ) : (
105
+ // #3289 §3 review: this renders in the same slot as `ConversationView` (never
106
+ // together), so it carries this page's `main` landmark here too — nothing above this
107
+ // slot supplies one.
108
+ <main className="flex h-full items-center justify-center" aria-label="Conversation">
109
+ <div className="flex flex-col items-center gap-3 text-center px-8">
110
+ <div className="flex h-10 w-10 items-center justify-center rounded-full bg-card">
111
+ <span className={`h-2.5 w-2.5 rounded-full ${cfg.dot}`} />
112
+ </div>
113
+ <p className="max-w-[320px] text-[15px] leading-relaxed text-muted-foreground">
114
+ {status === 'connecting'
115
+ ? `Connecting to ${url}…`
116
+ : status === 'error'
117
+ ? `Connection error — could not reach ${url}. Check the CLI is running and the URL is correct.`
118
+ : `Run ${identity.cliName} to start the CLI (WS transport starts automatically).`}
119
+ </p>
120
+ </div>
121
+ </main>
122
+ )}
123
+ </div>
124
+
125
+ <SessionInput
126
+ enabled={status === 'connected'}
127
+ onSubmit={(prompt) => send({ type: 'submit', prompt })}
128
+ />
129
+ </div>
130
+
131
+ {/* Right: agent activity panel (conditional) */}
132
+ {hasAgents && <AgentActivityPanel tasks={backgroundTasks} className="flex-1 bg-sidebar" />}
133
+ </div>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ function SessionInput({
139
+ enabled,
140
+ onSubmit,
141
+ }: {
142
+ enabled: boolean;
143
+ onSubmit: (prompt: string) => void;
144
+ }): React.ReactElement {
145
+ const [value, setValue] = useState('');
146
+
147
+ const handleSubmit = (): void => {
148
+ const trimmed = value.trim();
149
+ if (!trimmed) return;
150
+ onSubmit(trimmed);
151
+ setValue('');
152
+ };
153
+
154
+ return (
155
+ <div className="mx-auto flex w-full max-w-[760px] flex-shrink-0 items-end gap-2 px-6 pb-4">
156
+ <textarea
157
+ aria-label="Message"
158
+ className="max-h-[160px] min-h-[48px] flex-1 resize-none rounded-[22px] bg-card px-4 py-3 text-[15px] leading-relaxed text-foreground placeholder:text-subtle [field-sizing:content] focus:outline-none disabled:opacity-60"
159
+ rows={1}
160
+ placeholder={enabled ? 'Send a message…' : 'Connect to send messages'}
161
+ disabled={!enabled}
162
+ value={value}
163
+ onChange={(e) => setValue(e.target.value)}
164
+ onKeyDown={(e) => {
165
+ if (e.key === 'Enter' && !e.shiftKey) {
166
+ e.preventDefault();
167
+ handleSubmit();
168
+ }
169
+ }}
170
+ />
171
+ <button
172
+ className="h-12 rounded-[22px] bg-primary px-5 text-[14px] font-medium text-primary-foreground hover:opacity-90 disabled:bg-raised disabled:text-subtle"
173
+ disabled={!enabled || !value.trim()}
174
+ onClick={handleSubmit}
175
+ >
176
+ Send
177
+ </button>
178
+ </div>
179
+ );
180
+ }