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

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