@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,455 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ import { AgentActivityPanel } from './AgentActivityPanel.js';
4
+ import { AgentSwitcherSheet } from './AgentSwitcherSheet.js';
5
+ import { ProductMark, ProductWordmark } from './Brand.js';
6
+ import { Composer, GoalBar } from './Composer.js';
7
+ import { ConversationView } from './ConversationView.js';
8
+ import { Dialog } from './Dialog.js';
9
+ import { ExecutionDetailSheet } from './ExecutionDetailSheet.js';
10
+ import { HelpSheet } from './HelpSheet.js';
11
+ import { PermissionPrompt } from './PermissionPrompt.js';
12
+ import { PersonalUsageDashboard } from './PersonalUsageDashboard.js';
13
+ import { ProjectPanel } from './ProjectPanel.js';
14
+ import { SessionSidebar, SessionSidebarRail, sessionTitle } from './SessionSidebar.js';
15
+ import { ConnectionBanner, SessionNotices, SessionTitleBar } from './SessionSurfaceChrome.js';
16
+ import { SettingsScreen } from './SettingsScreen.js';
17
+ import { NARROW_WINDOW_QUERY, useMediaQuery } from '../hooks/use-media-query.js';
18
+
19
+ import type { IComposerHandle, IPickedFile } from './Composer.js';
20
+ import type { TSessionView } from './SessionSurfaceChrome.js';
21
+ import type { IWsSessionState } from '../hooks/useSessionClient.js';
22
+
23
+ /**
24
+ * The desktop session shell — the GUI analog of the TUI's presentation. Pure presentation over an
25
+ * `IWsSessionState`: no hooks, no transport, no session/command/permission logic — it renders the
26
+ * reconstructed session and forwards user intent through the reducer's `send`/`answer*`. The session
27
+ * sidebar runs the full height on the left once the host has listed its sessions (a host that cannot
28
+ * has none); beside it the title bar, the conversation, the pending question docked above the
29
+ * composer, and the background-activity rail on the right when work runs beside the conversation.
30
+ */
31
+
32
+ /** The column every part of the conversation shares, so messages, prompt and composer line up. */
33
+ const COLUMN = 'mx-auto w-full max-w-[760px] px-6';
34
+
35
+ /**
36
+ * Designed empty state shown before the first turn. Renders in the same slot as `ConversationView`
37
+ * (never together), so it carries the same `main` landmark and label (#3289 §3 review) — nothing
38
+ * above this slot supplies one.
39
+ */
40
+ function EmptyState(): React.ReactElement {
41
+ return (
42
+ <main className="flex h-full items-center justify-center" aria-label="Conversation">
43
+ <div className="gui-rise flex max-w-[440px] flex-col items-center gap-4 px-8 text-center">
44
+ <ProductMark size={40} />
45
+ <h2 className="text-[26px] font-semibold tracking-[-0.02em] text-foreground">
46
+ What are we working on?
47
+ </h2>
48
+ <p className="text-[15px] leading-relaxed text-muted-foreground">
49
+ Session connected. Send a message to start — the agent works in the host runtime and
50
+ asks you here before anything that needs your permission.
51
+ </p>
52
+ </div>
53
+ </main>
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Issue #3282 §3 — shown in place of the conversation while `sessionStatus.setupRequired` holds: the
59
+ * session is running (the GUI itself connected fine), but has no provider to reply with yet. "Set up
60
+ * provider" runs `/provider add` as a command; its questions dock above where the composer would be
61
+ * (the same `PermissionPrompt` any ask uses), and the panel clears itself once the session reports a
62
+ * real provider, live — no restart, no reload.
63
+ *
64
+ * Renders in the same slot as `ConversationView`/`EmptyState` (never together), so it carries the
65
+ * page's `main` landmark here too (#3289 §3 review) — nothing above this slot supplies one.
66
+ */
67
+ function SetupPanel({
68
+ onSetUp,
69
+ disabled,
70
+ }: {
71
+ onSetUp: () => void;
72
+ disabled: boolean;
73
+ }): React.ReactElement {
74
+ return (
75
+ <main className="flex h-full items-center justify-center" aria-label="Set up a provider">
76
+ <div className="gui-rise flex max-w-[440px] flex-col items-center gap-4 px-8 text-center">
77
+ <ProductMark size={40} />
78
+ <h2 className="text-[26px] font-semibold tracking-[-0.02em] text-foreground">
79
+ Connect a model provider to start.
80
+ </h2>
81
+ <p className="text-[15px] leading-relaxed text-muted-foreground">
82
+ Choose a provider and model to start. Enter a key if your provider requires one. You can
83
+ add more providers or change them later the same way.
84
+ </p>
85
+ <button
86
+ type="button"
87
+ onClick={onSetUp}
88
+ disabled={disabled}
89
+ className="rounded-lg bg-primary px-4 py-2 text-[14px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-60"
90
+ >
91
+ Set up provider
92
+ </button>
93
+ </div>
94
+ </main>
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The full desktop layout over an `IWsSessionState`. `surface` is an optional label shown next to the
100
+ * mark (e.g. "app").
101
+ */
102
+ export function SessionSurface({
103
+ state,
104
+ surface,
105
+ personalUsageEnabled = false,
106
+ onReconnect,
107
+ pickFiles,
108
+ getPathForFile,
109
+ onOpenMemoryInEditor,
110
+ }: {
111
+ state: IWsSessionState;
112
+ surface?: string;
113
+ /** Desktop shells opt in; embedded/session-only surfaces keep their existing chat-only contract. */
114
+ personalUsageEnabled?: boolean;
115
+ /**
116
+ * Present only when the host can restart the runtime after the connection is lost for good (issue
117
+ * #3280 §5, the desktop app); absent in the browser, where the banner says how to reopen it instead.
118
+ */
119
+ onReconnect?: () => Promise<void>;
120
+ /** Forwarded to the composer's attach button — present only on the desktop app (#3282 §4d). */
121
+ pickFiles?: () => Promise<readonly IPickedFile[]>;
122
+ /** Forwarded to the composer's drag-and-drop — present only on the desktop app (#3282 §4d). */
123
+ getPathForFile?: (file: File) => string;
124
+ /**
125
+ * The Project panel's memory "Open in editor" action — present only when the host can open a file
126
+ * in an external editor (the desktop app, #3282 §4c). Absent in the browser, where the action
127
+ * itself is hidden (only shown "if the host supports it").
128
+ */
129
+ onOpenMemoryInEditor?: (path: string) => void;
130
+ }): React.ReactElement {
131
+ const [view, setView] = useState<TSessionView>('chat');
132
+ const composerRef = useRef<IComposerHandle>(null);
133
+ const tasks = state.executionWorkspace?.entries ?? [];
134
+ // The main thread alone is this conversation; the rail earns its width only for work beside it.
135
+ const hasTasks = tasks.some((entry) => entry.kind !== 'main_thread');
136
+ // #3288 §1: the entry open in the detail sheet, resolved fresh from the live snapshot each render
137
+ // (so it reflects the entry's own updates, e.g. a Stop taking effect) rather than a stale copy.
138
+ const openEntry = tasks.find((entry) => entry.id === state.openEntryId) ?? null;
139
+ // A loop's `cancel` means "stop the loop" (`/loop stop <id>`) — never cancel-background-task,
140
+ // which for a self-paced loop would only cancel its disposable wake timer, leaving the loop
141
+ // itself active (see IExecutionWorkspaceEntry.loopId).
142
+ const stopExecutionEntry = (entry: { loopId?: string; sourceId: string }): void => {
143
+ if (entry.loopId !== undefined) {
144
+ state.send({ type: 'command', name: 'loop', args: `stop ${entry.loopId}` });
145
+ } else {
146
+ state.send({ type: 'cancel-background-task', taskId: entry.sourceId });
147
+ }
148
+ };
149
+ // #3282 §4 part b-3: the rail also earns its width for a schedule or a goal, even with no running
150
+ // background task/loop beside the conversation — "Work in progress" covers all four (issue #3282
151
+ // §4's Expected section), not only execution-workspace entries.
152
+ const currentGoal = state.sessionStatus?.goal ?? null;
153
+ const hasWorkInProgress = hasTasks || state.scheduledTasks.length > 0 || currentGoal !== null;
154
+ const isEmpty =
155
+ state.messages.length === 0 &&
156
+ !state.streamingText &&
157
+ !state.isThinking &&
158
+ state.activeTools.length === 0;
159
+ // #3282 §3: no provider is configured — the session is running (a placeholder stands in for the
160
+ // real one), but there is nothing to reply with yet.
161
+ const setupRequired = state.sessionStatus?.setupRequired === true;
162
+ const hasSessionList =
163
+ (state.sessionListing ?? null) !== null || state.sessionsError?.code === 'list_failed';
164
+ const sidebarOpen = hasSessionList && state.sessionSidebarOpen;
165
+ const usage = personalUsageEnabled && view === 'usage';
166
+ const project = view === 'project';
167
+ const listing = state.sessionListing ?? null;
168
+ const currentRow = listing?.sessions.find((session) => session.id === listing.currentSessionId);
169
+ // A rename seen on this page wins; otherwise the host's listing names the current session.
170
+ const title = state.sessionName ?? (currentRow ? sessionTitle(currentRow) : null);
171
+ // #3289 §2 — below `md` the open sidebar is a sheet over the conversation (the shared `Dialog`,
172
+ // #3331) rather than a column beside it: a dimmed backdrop, Esc, an outside click or choosing a
173
+ // session all close it again, and it traps Tab inside it while open.
174
+ const isNarrow = useMediaQuery(NARROW_WINDOW_QUERY);
175
+ const sheetActive = sidebarOpen && isNarrow;
176
+ const closeSidebar = (): void => state.setSessionSidebarOpen?.(false);
177
+ // The sheet's own opener — the rail's "Show sessions" button — unmounts in the SAME update that
178
+ // mounts the sheet (the ternary below swaps `SessionSidebarRail` for `Dialog`), so passing it as
179
+ // `Dialog`'s `restoreFocusTo` would only hand back a node already detached from the document by the
180
+ // time `Dialog` reads it — a `.focus()` on that node is a harmless no-op, not a restore. This effect
181
+ // does the real work instead: a `focusin` listener captures the opener the moment it actually had
182
+ // focus, while the sheet is closed; on close, a control that reappeared under the same `aria-label`
183
+ // (the rail's button, remounted) is as good a return address as the original node.
184
+ const sheetOpenerRef = useRef<HTMLElement | null>(null);
185
+ useEffect(() => {
186
+ if (sheetActive) return undefined;
187
+ const onFocusIn = (event: FocusEvent): void => {
188
+ if (event.target instanceof HTMLElement) sheetOpenerRef.current = event.target;
189
+ };
190
+ document.addEventListener('focusin', onFocusIn);
191
+ return () => document.removeEventListener('focusin', onFocusIn);
192
+ }, [sheetActive]);
193
+ const wasSheetActiveRef = useRef(false);
194
+ useEffect(() => {
195
+ if (wasSheetActiveRef.current && !sheetActive) {
196
+ const opener = sheetOpenerRef.current;
197
+ const label = opener?.getAttribute('aria-label');
198
+ const target =
199
+ opener && document.contains(opener)
200
+ ? opener
201
+ : label
202
+ ? document.querySelector<HTMLElement>(`[aria-label="${label}"]`)
203
+ : null;
204
+ target?.focus();
205
+ }
206
+ wasSheetActiveRef.current = sheetActive;
207
+ }, [sheetActive]);
208
+ // Choosing a session from the sidebar shows its conversation, whichever view was open — and, while
209
+ // it is a narrow-window sheet, closes it too, exactly as a backdrop click or Esc would.
210
+ const sidebarState: IWsSessionState = {
211
+ ...state,
212
+ switchSession: (sessionId) => {
213
+ setView('chat');
214
+ state.switchSession?.(sessionId);
215
+ if (sheetActive) closeSidebar();
216
+ },
217
+ newSession: () => {
218
+ setView('chat');
219
+ state.newSession?.();
220
+ if (sheetActive) closeSidebar();
221
+ },
222
+ };
223
+ const sidebarPanel = (
224
+ <SessionSidebar
225
+ state={sidebarState}
226
+ brand={<ProductWordmark surface={surface} />}
227
+ className={
228
+ sheetActive
229
+ ? 'h-full'
230
+ : 'absolute inset-y-0 left-0 z-30 shadow-2xl shadow-black/40 md:static md:z-auto md:shadow-none'
231
+ }
232
+ />
233
+ );
234
+
235
+ return (
236
+ <div className="agent-ui relative flex h-full bg-background text-foreground">
237
+ {hasSessionList ? (
238
+ sidebarOpen ? (
239
+ isNarrow ? (
240
+ <Dialog
241
+ open={sheetActive}
242
+ onClose={closeSidebar}
243
+ title="Sessions"
244
+ panelClassName="mr-auto flex h-full w-[272px] max-w-[85vw] flex-col self-stretch overflow-hidden rounded-2xl bg-sidebar shadow-2xl shadow-black/40 focus:outline-none"
245
+ >
246
+ {sidebarPanel}
247
+ </Dialog>
248
+ ) : (
249
+ // Wide windows keep it as a static column beside the conversation.
250
+ sidebarPanel
251
+ )
252
+ ) : (
253
+ <SessionSidebarRail state={sidebarState} />
254
+ )
255
+ ) : null}
256
+
257
+ <div className="flex min-w-0 flex-1 flex-col">
258
+ <SessionTitleBar
259
+ status={state.status}
260
+ surface={surface}
261
+ title={title}
262
+ workspace={state.sessionStatus?.workspace}
263
+ showBrand={!sidebarOpen}
264
+ view={view}
265
+ onView={setView}
266
+ personalUsageEnabled={personalUsageEnabled}
267
+ connectionLost={state.connectionLost ?? false}
268
+ />
269
+
270
+ {usage ? (
271
+ <div className="min-h-0 flex-1">
272
+ <PersonalUsageDashboard state={state} />
273
+ {/* A gated turn waits on this answer, so it shows over whatever view is open. */}
274
+ <PermissionPrompt
275
+ layout="modal"
276
+ prompts={state.pendingPrompts}
277
+ onAnswerPermission={state.answerPermission}
278
+ onAnswerAsk={state.answerAsk}
279
+ ownDriverId={state.ownDriverId}
280
+ />
281
+ </div>
282
+ ) : project ? (
283
+ <div className="min-h-0 flex-1">
284
+ <ProjectPanel state={state} onOpenMemoryInEditor={onOpenMemoryInEditor} />
285
+ <PermissionPrompt
286
+ layout="modal"
287
+ prompts={state.pendingPrompts}
288
+ onAnswerPermission={state.answerPermission}
289
+ onAnswerAsk={state.answerAsk}
290
+ ownDriverId={state.ownDriverId}
291
+ />
292
+ </div>
293
+ ) : (
294
+ <div className="flex min-h-0 flex-1">
295
+ <div className="flex min-w-0 flex-1 flex-col">
296
+ <ConnectionBanner
297
+ status={state.status}
298
+ connectionLost={state.connectionLost ?? false}
299
+ onReconnect={onReconnect}
300
+ />
301
+ <div className="min-h-0 flex-1 overflow-hidden">
302
+ {setupRequired ? (
303
+ <SetupPanel
304
+ onSetUp={() => state.send({ type: 'command', name: 'provider', args: 'add' })}
305
+ disabled={state.status !== 'connected'}
306
+ />
307
+ ) : isEmpty ? (
308
+ <EmptyState />
309
+ ) : (
310
+ <ConversationView
311
+ messages={state.messages}
312
+ activeTools={state.activeTools}
313
+ streamingText={state.streamingText}
314
+ isThinking={state.isThinking}
315
+ ownDriverId={state.ownDriverId}
316
+ />
317
+ )}
318
+ </div>
319
+ <div
320
+ className={`${COLUMN} relative flex flex-shrink-0 flex-col gap-2 pb-4 before:pointer-events-none before:absolute before:inset-x-0 before:-top-8 before:h-8 before:bg-gradient-to-t before:from-background before:to-transparent`}
321
+ >
322
+ <GoalBar
323
+ status={state.sessionStatus ?? null}
324
+ onStop={() => state.send({ type: 'command', name: 'goal', args: 'cancel' })}
325
+ />
326
+ <PermissionPrompt
327
+ layout="dock"
328
+ prompts={state.pendingPrompts}
329
+ onAnswerPermission={state.answerPermission}
330
+ onAnswerAsk={state.answerAsk}
331
+ ownDriverId={state.ownDriverId}
332
+ onFocusReturn={() => composerRef.current?.focus()}
333
+ />
334
+ {/* #3282 §3: hidden while there is no provider to send to — the setup panel's own
335
+ button is the only way in, so nobody types into a composer that goes nowhere. */}
336
+ {setupRequired ? null : (
337
+ <Composer
338
+ ref={composerRef}
339
+ catalog={state.commandCatalog ?? null}
340
+ status={state.sessionStatus ?? null}
341
+ connected={state.status === 'connected'}
342
+ running={state.isThinking}
343
+ onStop={() => state.send({ type: 'abort' })}
344
+ queued={state.queuedPrompt}
345
+ pickFiles={pickFiles}
346
+ getPathForFile={getPathForFile}
347
+ onCancelQueue={() => {
348
+ state.send({ type: 'cancel-queue' });
349
+ // No push confirms a cleared queue (unlike a resolved prompt); ask, so the row
350
+ // reliably disappears instead of trusting the clear went through.
351
+ state.send({ type: 'get-pending' });
352
+ }}
353
+ onCommand={(name, args) =>
354
+ state.send({ type: 'command', name, ...(args ? { args } : {}) })
355
+ }
356
+ modelList={state.modelList}
357
+ onRequestModelList={() => state.requestModelList()}
358
+ onSilentCommand={(name, args) => state.sendCommandSilently(name, args)}
359
+ // #3282 §4b: "Manage providers…" now opens Settings at the Providers & Models
360
+ // section, replacing the model menu's former server-driven profile flow.
361
+ onManageProviders={() => state.openSettings('providers')}
362
+ onSubmit={(prompt) => {
363
+ if (!prompt.startsWith('/')) {
364
+ state.send({ type: 'submit', prompt });
365
+ return;
366
+ }
367
+ const [name, ...rest] = prompt.slice(1).trim().split(/\s+/u);
368
+ if (!name) return;
369
+ const args = rest.join(' ');
370
+ state.send({ type: 'command', name, ...(args ? { args } : {}) });
371
+ }}
372
+ />
373
+ )}
374
+ </div>
375
+ </div>
376
+
377
+ {hasWorkInProgress && (
378
+ <aside aria-label="Agents" className="flex w-72 flex-shrink-0 overflow-hidden bg-sidebar">
379
+ <AgentActivityPanel
380
+ tasks={tasks}
381
+ className="flex-1"
382
+ selectedEntryId={state.openEntryId ?? undefined}
383
+ onSelect={(entry) => state.openExecutionDetail(entry.id)}
384
+ onReturnToConversation={() => state.closeExecutionDetail()}
385
+ onStop={stopExecutionEntry}
386
+ schedules={state.scheduledTasks}
387
+ goal={currentGoal}
388
+ onPauseSchedule={state.pauseSchedule}
389
+ onResumeSchedule={state.resumeSchedule}
390
+ onDeleteSchedule={state.deleteSchedule}
391
+ onCancelGoal={() => state.send({ type: 'command', name: 'goal', args: 'cancel' })}
392
+ />
393
+ </aside>
394
+ )}
395
+
396
+ <ExecutionDetailSheet
397
+ entry={openEntry}
398
+ status={state.executionDetailStatus}
399
+ records={state.executionDetailRecords}
400
+ error={state.executionDetailError}
401
+ complete={state.executionDetailComplete}
402
+ onClose={() => state.closeExecutionDetail()}
403
+ onLoadMore={() => state.loadMoreExecutionDetail()}
404
+ onStop={openEntry ? () => stopExecutionEntry(openEntry) : undefined}
405
+ />
406
+ </div>
407
+ )}
408
+ </div>
409
+
410
+ <SessionNotices state={state} />
411
+ <SettingsScreen state={state} />
412
+ <AgentSwitcherSheet state={state} />
413
+ <HelpSheet open={state.helpOpen} onClose={() => state.closeHelp()} catalog={state.commandCatalog} />
414
+ </div>
415
+ );
416
+ }
417
+
418
+ /**
419
+ * A centered chrome frame for the pre-session (loading) and fatal states — reused by app shells. Its
420
+ * content area is this page's `main` landmark (#3289 §3 review): nothing above `CenteredChrome`'s
421
+ * three call sites (starting, fatal, the trust question) supplies one, and none of the three ever
422
+ * render alongside `SessionSurface`'s own. `header` stays a sibling of `main`, not inside it, so it
423
+ * keeps its own implicit `banner` role.
424
+ */
425
+ export function CenteredChrome({
426
+ tone,
427
+ children,
428
+ }: {
429
+ tone: 'muted' | 'fatal';
430
+ children: React.ReactNode;
431
+ }): React.ReactElement {
432
+ return (
433
+ <div className="agent-ui flex h-full flex-col bg-background text-foreground">
434
+ <header className="flex h-12 flex-shrink-0 items-center px-5">
435
+ <ProductWordmark />
436
+ </header>
437
+ <main className="flex flex-1 items-center justify-center">
438
+ <div className="gui-rise flex max-w-[520px] flex-col items-center gap-4 px-8 text-center">
439
+ <span
440
+ className={`h-2.5 w-2.5 rounded-full ${
441
+ tone === 'fatal' ? 'bg-destructive' : 'animate-pulse bg-warning'
442
+ }`}
443
+ />
444
+ <div
445
+ className={`text-[15px] leading-relaxed ${
446
+ tone === 'fatal' ? 'text-foreground' : 'text-muted-foreground'
447
+ }`}
448
+ >
449
+ {children}
450
+ </div>
451
+ </div>
452
+ </main>
453
+ </div>
454
+ );
455
+ }