@robota-sdk/agent-ui-web 3.2.1 → 3.3.1

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 (56) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/index.cjs +161 -118
  3. package/dist/index.d.cts +65 -63
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +65 -63
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +161 -118
  8. package/dist/index.js.map +1 -1
  9. package/docs/SPEC.md +6 -1
  10. package/package.json +6 -6
  11. package/src/client/ws-session-client.ts +1 -1
  12. package/src/clipboard.ts +2 -2
  13. package/src/components/AgentActivityPanel.tsx +46 -39
  14. package/src/components/AgentSwitcherSheet.tsx +2 -2
  15. package/src/components/Composer.tsx +40 -37
  16. package/src/components/ConversationView.tsx +14 -14
  17. package/src/components/Dialog.tsx +6 -6
  18. package/src/components/DiffLines.tsx +2 -2
  19. package/src/components/ExecutionDetailSheet.tsx +1 -1
  20. package/src/components/HelpSheet.tsx +2 -2
  21. package/src/components/PermissionPrompt.tsx +22 -22
  22. package/src/components/PopupMenu.tsx +1 -1
  23. package/src/components/ProjectPanel.tsx +2 -2
  24. package/src/components/SessionMonitor.tsx +1 -1
  25. package/src/components/SessionSidebar.tsx +8 -8
  26. package/src/components/SessionSurface.tsx +83 -56
  27. package/src/components/SessionSurfaceChrome.tsx +8 -8
  28. package/src/components/SettingsGeneralSection.tsx +1 -1
  29. package/src/components/SettingsMcpSection.tsx +1 -1
  30. package/src/components/SettingsPermissionsSection.tsx +1 -1
  31. package/src/components/SettingsPluginsSection.tsx +1 -1
  32. package/src/components/SettingsProvidersSection.tsx +3 -3
  33. package/src/components/SettingsScreen.tsx +3 -3
  34. package/src/components/StatusControls.tsx +12 -12
  35. package/src/components/composer-attachments.ts +5 -5
  36. package/src/driver-labels.ts +3 -3
  37. package/src/execution-entry-status.ts +1 -1
  38. package/src/goal-view.ts +1 -1
  39. package/src/hooks/command-menu.ts +2 -2
  40. package/src/hooks/excluded-commands.ts +2 -2
  41. package/src/hooks/prompt-state.ts +4 -4
  42. package/src/hooks/server-message-handling.ts +13 -11
  43. package/src/hooks/session-client-types.ts +36 -30
  44. package/src/hooks/ui-intent-state.ts +6 -6
  45. package/src/hooks/use-agent-switcher-state.ts +2 -2
  46. package/src/hooks/use-app-view-requests.ts +8 -1
  47. package/src/hooks/use-media-query.ts +1 -1
  48. package/src/hooks/use-model-list.ts +1 -1
  49. package/src/hooks/use-project-panel.ts +1 -1
  50. package/src/hooks/use-schedules-state.ts +4 -4
  51. package/src/hooks/use-session-directory.ts +11 -6
  52. package/src/hooks/use-settings-state.ts +4 -4
  53. package/src/hooks/useSessionClient.ts +81 -50
  54. package/src/index.ts +5 -5
  55. package/src/schedule-view.ts +1 -1
  56. package/src/styles/surface.css +1 -1
@@ -7,7 +7,7 @@ import { Dialog } from './Dialog.js';
7
7
 
8
8
  import type { TCommandCatalog } from '../hooks/session-client-types.js';
9
9
 
10
- /** One keyboard shortcut this surface answers — #3282 §4e. */
10
+ /** One keyboard shortcut this surface answers */
11
11
  interface IShortcut {
12
12
  readonly keys: string;
13
13
  readonly description: string;
@@ -22,7 +22,7 @@ const SHORTCUTS: readonly IShortcut[] = [
22
22
  ];
23
23
 
24
24
  /**
25
- * The GUI's Help sheet (#3282 §4e), a `Dialog` opened by `/help` — client-only: `useSessionClient.ts`'s
25
+ * The GUI's Help sheet, a `Dialog` opened by `/help` — client-only: `useSessionClient.ts`'s
26
26
  * `send` catches `/help` before it ever reaches the session and opens this instead, so there is no
27
27
  * round trip and nothing terminal-style to replace it with. It lists the same commands and skills the
28
28
  * `/` menu offers (`commandMenuFor`, unfiltered by any query — an excluded command has no row here
@@ -19,19 +19,19 @@ import type { TActionResponse } from '@robota-sdk/agent-interface-transport';
19
19
  import type { TDriverId } from '@robota-sdk/agent-interface-session';
20
20
 
21
21
  /**
22
- * Renders the owner's pending permission/ask prompts (REMOTE-007/009). Under local == remote the paired
22
+ * Renders the owner's pending permission/ask prompts. Under local == remote the paired
23
23
  * browser owner answers its OWN prompts; the first pending prompt is shown. A gated tool call blocks
24
24
  * until answered, so this is on the critical path — not decorative.
25
25
  *
26
26
  * `modal` covers the page; `dock` sits above the composer, as desktop agent apps place a pending
27
27
  * question next to where the answer is typed. Once armed and focused, its keys answer it: 1–9 picks
28
28
  * an option, Esc cancels a question or denies a permission — but it never takes that focus away from
29
- * a field the person is typing in (#3280 §1). A click on a button always answers at once. An ask whose
29
+ * a field the person is typing in. A click on a button always answers at once. An ask whose
30
30
  * request sets `allowFreeText` also shows a text field (masked when `masked` is set); Enter there
31
- * submits it, and its own keys never reach the prompt's shortcuts (#3280 §3). The field's typed value
31
+ * submits it, and its own keys never reach the prompt's shortcuts. The field's typed value
32
32
  * lives only in `FreeTextField`, keyed by `prompt.id`: a cancelled or answered prompt's text can
33
33
  * never reach the next prompt's render, masked or not, because the field is a fresh component
34
- * instance rather than a value carried over in this component's own state (#3280 §3 follow-up).
34
+ * instance rather than a value carried over in this component's own state.
35
35
  *
36
36
  * An ask whose `maxSelect` is above 1 is a multi-select: each option is a toggle (a click or 1–9 ticks
37
37
  * it, nothing answers yet), and Submit — or Enter at the prompt, once the selection is allowed —
@@ -43,7 +43,7 @@ interface IPermissionPromptProps {
43
43
  prompts: readonly TPendingPrompt[];
44
44
  onAnswerPermission: (id: string, result: boolean) => void;
45
45
  onAnswerAsk: (id: string, response: TActionResponse) => void;
46
- /** This connection's own driver id (§3289 §3): a prompt raised by its own turn shows no requester. */
46
+ /** This connection's own driver id: a prompt raised by its own turn shows no requester. */
47
47
  ownDriverId?: TDriverId | null;
48
48
  layout?: 'modal' | 'dock';
49
49
  /** How long a new prompt waits before its keys answer it; tests pass their own. */
@@ -72,7 +72,7 @@ function isEditableElement(element: Element | null): boolean {
72
72
  }
73
73
 
74
74
  /**
75
- * #3282 §2 (part 2) — the one destructive option in the provider profile action menu (the ask built
75
+ * The one destructive option in the provider profile action menu (the ask built
76
76
  * by `askProviderProfileAction` in `agent-command`'s `provider-command-profile.ts`, `request.id`
77
77
  * `'provider-profile-action'`): "Delete" answers to no digit and renders apart from Switch/Edit/Test/
78
78
  * Duplicate, styled destructive — the exact audited bug ("Delete looks the same as Switch, answers to
@@ -132,7 +132,7 @@ export function PermissionPrompt({
132
132
  const promptId = prompt?.id;
133
133
  const containerRef = useRef<HTMLDivElement>(null);
134
134
  const fieldRef = useRef<HTMLInputElement>(null);
135
- // A question with a text field (#3280 §3): the field is the prompt's primary control, so focus goes
135
+ // A question with a text field: the field is the prompt's primary control, so focus goes
136
136
  // there instead of the container wherever this component would otherwise focus the container itself.
137
137
  const hasFreeText = prompt?.kind === 'ask' && prompt.request.allowFreeText === true;
138
138
  const [armedId, setArmedId] = useState<string | undefined>(undefined);
@@ -162,8 +162,8 @@ export function PermissionPrompt({
162
162
  // Answering one prompt renders the next into the same buttons, so a button focused to answer the
163
163
  // last one would take Enter, Space or a held key as an answer to this one. Focus goes back to the
164
164
  // prompt itself, whose keys wait for the prompt to arm. A reused button is still there to find this
165
- // way — but the free-text field is its own component, remounted (not updated) for the new prompt.id
166
- // (#3280 §3 follow-up), and removing a focused DOM node drops focus to <body> as an intrinsic side
165
+ // way — but the free-text field is its own component, remounted (not updated) for the new prompt.id,
166
+ // and removing a focused DOM node drops focus to <body> as an intrinsic side
167
167
  // effect, before this effect ever runs. `pendingFocusReturnRef` (set at answer time, and not yet
168
168
  // cleared — the effect that clears it is a passive one, ordered after this layout effect) still
169
169
  // remembers that focus was in this prompt, so that case is reclaimed too.
@@ -178,7 +178,7 @@ export function PermissionPrompt({
178
178
  }
179
179
  }, [promptId]);
180
180
  // Once armed the prompt takes focus, so its keys answer it rather than type wherever the person
181
- // already is — UNLESS that is an editable field, whose keystrokes must never be stolen (#3280 §1).
181
+ // already is — UNLESS that is an editable field, whose keystrokes must never be stolen.
182
182
  useEffect(() => {
183
183
  if (!armed) return;
184
184
  if (isEditableElement(document.activeElement)) return;
@@ -199,8 +199,8 @@ export function PermissionPrompt({
199
199
  }, [promptId, onFocusReturn]);
200
200
  if (!prompt) return null;
201
201
 
202
- // #3282 §2 (part 2): the provider-delete confirmation (`buildProviderDelete` in agent-command's
203
- // `provider-command-profile-lifecycle.ts`) is now built on the shared `ConfirmDialog` (#3331)
202
+ // The provider-delete confirmation (`buildProviderDelete` in agent-command's
203
+ // `provider-command-profile-lifecycle.ts`) is now built on the shared `ConfirmDialog`
204
204
  // instead of the generic ask grid — a destructive-styled "Delete", Cancel focused by default, and
205
205
  // no accidental backdrop-click dismissal. `confirmAction`'s own `CONFIRM_YES`/`CONFIRM_NO` values
206
206
  // ('yes'/'no', from `@robota-sdk/agent-core`) are answered directly; this bypasses the rest of this
@@ -220,7 +220,7 @@ export function PermissionPrompt({
220
220
  );
221
221
  }
222
222
 
223
- // REMOTE-014 E5 (display-only): the prompt belongs to the driver whose turn raised it. Shown so the owner
223
+ // Display-only: the prompt belongs to the driver whose turn raised it. Shown so the owner
224
224
  // can tell a co-driver's tool-gate from their own — it NEVER changes who is authorized to answer (owner).
225
225
  const requesterLabel =
226
226
  prompt.requesterDriverId && !isSameSurface(prompt.requesterDriverId, ownDriverId)
@@ -301,7 +301,7 @@ export function PermissionPrompt({
301
301
  if (index < 2) answerPermission(prompt.id, index === 0);
302
302
  return;
303
303
  }
304
- // #3282 §2: a destructive option (Delete) is excluded from the digit-answerable list entirely —
304
+ // A destructive option (Delete) is excluded from the digit-answerable list entirely —
305
305
  // no stray digit, including the one it happened to render at before this menu was reordered, can
306
306
  // ever answer it.
307
307
  const digitOptions = (prompt.request.options ?? []).filter(
@@ -313,7 +313,7 @@ export function PermissionPrompt({
313
313
  if (multi) toggleOption(option.value);
314
314
  else answerAsk(prompt.id, { type: 'answer', values: [option.value] });
315
315
  };
316
- // #3288: an Edit/Write request carries the same server-built diff `tool_end` would show.
316
+ // An Edit/Write request carries the same server-built diff `tool_end` would show.
317
317
  const hasDiff = prompt.kind === 'permission' && (prompt.diffLines?.length ?? 0) > 0;
318
318
  const shellCommand =
319
319
  prompt.kind === 'permission' && typeof prompt.toolArgs['command'] === 'string'
@@ -322,7 +322,7 @@ export function PermissionPrompt({
322
322
  const isShellCommand = prompt.kind === 'permission' && prompt.toolName === 'Bash' && shellCommand !== undefined;
323
323
  const askRequestId = prompt.kind === 'ask' ? prompt.request.id : undefined;
324
324
  const askOptions = prompt.kind === 'ask' ? (prompt.request.options ?? []) : [];
325
- // #3282 §2: Delete (in the provider profile action menu) renders apart from the rest, destructive-
325
+ // Delete (in the provider profile action menu) renders apart from the rest, destructive-
326
326
  // styled and with no digit shortcut — every other ask's options are unaffected (`destructiveAskOption`
327
327
  // stays undefined, so `primaryAskOptions` is just `askOptions`, same order, same shortcuts).
328
328
  const destructiveAskOption = askOptions.find((opt) => isDestructiveAskOption(askRequestId, opt.value));
@@ -381,10 +381,10 @@ export function PermissionPrompt({
381
381
  </>
382
382
  )}
383
383
  </p>
384
- {/* #3288 §2: an Edit/Write request with a server-built diff preview shows "Edit
384
+ {/* An Edit/Write request with a server-built diff preview shows "Edit
385
385
  <path>" (or "Background agent <label> wants to edit <path>") and the diff, in
386
386
  place of "Allow <tool> to run?" and raw arguments.
387
- Issue #3288 §1: a background agent's own request ALWAYS names it — whether or
387
+ A background agent's own request ALWAYS names it — whether or
388
388
  not there's a diff to show — so this never reads as an unattributed ask for
389
389
  exactly the riskiest calls (a diff-bearing Edit/Write). */}
390
390
  {prompt.requester?.kind === 'background-agent' ? (
@@ -505,7 +505,7 @@ export function PermissionPrompt({
505
505
  ))}
506
506
  </div>
507
507
  )}
508
- {/* #3282 §2: Delete sits apart at the bottom, styled destructive, with no digit
508
+ {/* Delete sits apart at the bottom, styled destructive, with no digit
509
509
  shortcut — the audited bug was that it looked and behaved just like Switch. */}
510
510
  {destructiveAskOption && (
511
511
  <div className="flex flex-wrap items-start gap-3">
@@ -579,7 +579,7 @@ const BUTTON =
579
579
  const PRIMARY_BUTTON = `${BUTTON} bg-primary text-primary-foreground hover:opacity-90`;
580
580
  const SECONDARY_BUTTON = `${BUTTON} bg-raised text-foreground hover:bg-hover`;
581
581
  const GHOST_BUTTON = `${BUTTON} text-muted-foreground hover:bg-hover hover:text-foreground`;
582
- /** #3282 §2: a destructive ask option (Delete) — visually distinct from Switch/Edit/Test/Duplicate. */
582
+ /** A destructive ask option (Delete) — visually distinct from Switch/Edit/Test/Duplicate. */
583
583
  const DESTRUCTIVE_BUTTON = `${BUTTON} bg-destructive/12 text-destructive hover:bg-destructive/20`;
584
584
 
585
585
  /** Imperative escape hatch a parent can use to wipe the field's value without owning it. */
@@ -628,7 +628,7 @@ const FreeTextField = forwardRef<IFreeTextFieldHandle, IFreeTextFieldProps>(func
628
628
  onCancel();
629
629
  };
630
630
  /**
631
- * A key that reaches the field types into it — the prompt's own shortcuts never see it (#3280 §3).
631
+ * A key that reaches the field types into it — the prompt's own shortcuts never see it.
632
632
  * The Enter that only finishes an IME composition (Korean/Japanese/Chinese) must not submit the
633
633
  * still-uncommitted text — `isComposing` (or `keyCode` 229, on browsers that predate it) marks that
634
634
  * Enter; the keystroke is left alone so the browser can commit the composition normally.
@@ -738,7 +738,7 @@ function ToolArgs({ args }: { args: unknown }): React.ReactElement | null {
738
738
  }
739
739
 
740
740
  /**
741
- * #3288: a Shell request shows just its command — and the working directory only when the server
741
+ * A Shell request shows just its command — and the working directory only when the server
742
742
  * says it differs from the workspace — instead of every raw argument.
743
743
  */
744
744
  function ShellCommandPreview({
@@ -22,7 +22,7 @@ export interface IPopupMenuSection {
22
22
  }
23
23
 
24
24
  /**
25
- * A HIG pop-up menu (#3282 §2): `role="menu"`, its choices as `menuitemradio` with `aria-checked`
25
+ * A HIG pop-up menu: `role="menu"`, its choices as `menuitemradio` with `aria-checked`
26
26
  * (or `menuitem` for a plain action, e.g. "Manage providers…"), grouped into optional labelled
27
27
  * sections. Arrow keys move a roving focus, Enter/Space activates the focused item, and Esc — or a
28
28
  * click outside — closes the menu and returns focus to the trigger that opened it, so the person
@@ -13,8 +13,8 @@ import type {
13
13
  } from '../hooks/session-client-types.js';
14
14
 
15
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
16
+ * 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: everything the host implements is usable from
18
18
  * the GUI through organized screens — this is git status/diff and project memory's screen. Checkpoints
19
19
  * (rewind) are deliberately absent (see `agent-gui-web/docs/SPEC.md`'s design decisions): edit
20
20
  * checkpoints only work on a host that can prove a write stays inside the project
@@ -102,7 +102,7 @@ export function SessionMonitor({ wsUrl, className }: ISessionMonitorProps): Reac
102
102
  ownDriverId={ownDriverId}
103
103
  />
104
104
  ) : (
105
- // #3289 §3 review: this renders in the same slot as `ConversationView` (never
105
+ // This renders in the same slot as `ConversationView` (never
106
106
  // together), so it carries this page's `main` landmark here too — nothing above this
107
107
  // slot supplies one.
108
108
  <main className="flex h-full items-center justify-center" aria-label="Conversation">
@@ -32,7 +32,7 @@ export function formatUpdatedAt(updatedAt: string, now: number = Date.now()): st
32
32
  }
33
33
 
34
34
  /**
35
- * A session's title (#3289 §1): its name, else its stable first-message title, else "New session".
35
+ * A session's title: its name, else its stable first-message title, else "New session".
36
36
  * Never `preview` — that field is the raw latest reply, which is what changes every turn and is what
37
37
  * this title exists to stop showing.
38
38
  */
@@ -75,12 +75,12 @@ interface IMenuAnchor {
75
75
  }
76
76
 
77
77
  /**
78
- * #3189 — this workspace's sessions on the left of the conversation, as in Claude Code Desktop:
78
+ * This workspace's sessions on the left of the conversation, as in Claude Code Desktop:
79
79
  * start a new one, or click another to make it current. Rows the host could not read are listed,
80
80
  * disabled, so a damaged session never looks deleted. A refused switch comes back as a notice.
81
81
  * A host that keeps several sessions live marks the rows running now and counts who else is on them.
82
82
  *
83
- * #3289 §1 — each row also carries a "More" menu (or right-click) with Rename (inline) and Delete
83
+ * Each row also carries a "More" menu (or right-click) with Rename (inline) and Delete
84
84
  * (confirmed). Rename on the current row reuses the `/rename` command, which also updates the live
85
85
  * session's own name and title bar; rename on any other row writes the stored record directly.
86
86
  */
@@ -99,7 +99,7 @@ export function SessionSidebar({
99
99
  const unreadable = listing?.unreadableSessionIds ?? [];
100
100
  const lockedLine = lockedSessionsLine(listing?.lockedSessionCount);
101
101
  const failed = state.sessionsError?.code === 'list_failed' ? state.sessionsError.message : null;
102
- // #3282 §2 (part 2): session switching is disabled while disconnected, like the status chips and
102
+ // Session switching is disabled while disconnected, like the status chips and
103
103
  // the `/` command menu — a switch could not reach the host either.
104
104
  const connected = state.status === 'connected';
105
105
 
@@ -203,7 +203,7 @@ export function SessionSidebar({
203
203
  const isCurrent = session.id === current;
204
204
  const others = otherClients(session, isCurrent);
205
205
  const isRenaming = renamingId === session.id;
206
- // #3289 §3 review: the explicit `aria-label` below replaces name-from-content outright, so
206
+ // The explicit `aria-label` below replaces name-from-content outright, so
207
207
  // the live dot's and "N other(s)" span's own text no longer reaches the accessible name —
208
208
  // folded back in here as a description instead, alongside the relative time.
209
209
  const statusParts = [
@@ -238,12 +238,12 @@ export function SessionSidebar({
238
238
  <button
239
239
  type="button"
240
240
  aria-current={isCurrent ? 'true' : undefined}
241
- // #3289 §3: an explicit name + description — the row's own visible text has no
241
+ // An explicit name + description — the row's own visible text has no
242
242
  // whitespace between its parts, so an unlabelled button reads as one run-together
243
243
  // string ("Title validation fixjust now") to a screen reader.
244
244
  aria-label={sessionTitle(session)}
245
245
  aria-describedby={describedBy}
246
- // #3282 §2 (part 2): while disconnected, the tooltip says why instead of previewing.
246
+ // While disconnected, the tooltip says why instead of previewing.
247
247
  title={connected ? session.preview || session.id : 'Reconnecting…'}
248
248
  disabled={!connected}
249
249
  onClick={() => {
@@ -409,7 +409,7 @@ export function SessionSidebar({
409
409
 
410
410
  /** The collapsed sidebar: a narrow rail that opens it again. */
411
411
  export function SessionSidebarRail({ state }: { state: IWsSessionState }): React.ReactElement {
412
- // #3282 §2 (part 2): starting a session is disabled while disconnected, matching the open sidebar.
412
+ // Starting a session is disabled while disconnected, matching the open sidebar.
413
413
  const connected = state.status === 'connected';
414
414
  return (
415
415
  <div className="flex w-12 flex-shrink-0 flex-col items-center gap-1 bg-sidebar py-2.5">
@@ -38,7 +38,7 @@ const APP_VIEW_HOST_VERSION = '1.0.0';
38
38
 
39
39
  /**
40
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
41
+ * (never together), so it carries the same `main` landmark and label — nothing
42
42
  * above this slot supplies one.
43
43
  */
44
44
  function EmptyState(): React.ReactElement {
@@ -59,14 +59,14 @@ function EmptyState(): React.ReactElement {
59
59
  }
60
60
 
61
61
  /**
62
- * Issue #3282 §3 — shown in place of the conversation while `sessionStatus.setupRequired` holds: the
62
+ * Shown in place of the conversation while `sessionStatus.setupRequired` holds: the
63
63
  * session is running (the GUI itself connected fine), but has no provider to reply with yet. "Set up
64
64
  * provider" runs `/provider add` as a command; its questions dock above where the composer would be
65
65
  * (the same `PermissionPrompt` any ask uses), and the panel clears itself once the session reports a
66
66
  * real provider, live — no restart, no reload.
67
67
  *
68
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.
69
+ * page's `main` landmark here too — nothing above this slot supplies one.
70
70
  */
71
71
  function SetupPanel({
72
72
  onSetUp,
@@ -106,7 +106,7 @@ function SetupPanel({
106
106
  export function SessionSurface({
107
107
  state,
108
108
  surface,
109
- personalUsageEnabled = false,
109
+ personalUsageEnabled: personalUsageRequested = false,
110
110
  draftMode = 'persistent',
111
111
  onReconnect,
112
112
  pickFiles,
@@ -121,23 +121,25 @@ export function SessionSurface({
121
121
  /** Secure desktop discards unsent text and attachment paths when the session closes. */
122
122
  draftMode?: 'persistent' | 'volatile';
123
123
  /**
124
- * Present only when the host can restart the runtime after the connection is lost for good (issue
125
- * #3280 §5, the desktop app); absent in the browser, where the banner says how to reopen it instead.
124
+ * Present only when the host can restart the runtime after the connection is lost for good (the
125
+ * desktop app); absent in the browser, where the banner says how to reopen it instead.
126
126
  */
127
127
  onReconnect?: () => Promise<void>;
128
- /** Forwarded to the composer's attach button — present only on the desktop app (#3282 §4d). */
128
+ /** Forwarded to the composer's attach button — present only on the desktop app. */
129
129
  pickFiles?: () => Promise<readonly IPickedFile[]>;
130
- /** Forwarded to the composer's drag-and-drop — present only on the desktop app (#3282 §4d). */
130
+ /** Forwarded to the composer's drag-and-drop — present only on the desktop app. */
131
131
  getPathForFile?: (file: File) => string;
132
132
  /**
133
133
  * The Project panel's memory "Open in editor" action — present only when the host can open a file
134
- * in an external editor (the desktop app, #3282 §4c). Absent in the browser, where the action
134
+ * in an external editor (the desktop app). Absent in the browser, where the action
135
135
  * itself is hidden (only shown "if the host supports it").
136
136
  */
137
137
  onOpenMemoryInEditor?: (path: string) => void;
138
138
 
139
139
  appViewSandboxUrl?: string;
140
140
  }): React.ReactElement {
141
+ // An observer sees the session but changes nothing; the host stays the authority, this is presentation.
142
+ const readOnly = state.role === 'observe';
141
143
  const [view, setView] = useState<TSessionView>('chat');
142
144
  const { identity } = useProductIdentity();
143
145
  const appViewHost = useMemo(
@@ -150,11 +152,13 @@ export function SessionSurface({
150
152
  }),
151
153
  [appViewSandboxUrl, state.readAppView, state.callAppTool, identity.displayName, surface],
152
154
  );
155
+ // The personal usage read is not admitted for an observer.
156
+ const personalUsageEnabled = personalUsageRequested && !readOnly;
153
157
  const composerRef = useRef<IComposerHandle>(null);
154
158
  const tasks = state.executionWorkspace?.entries ?? [];
155
159
  // The main thread alone is this conversation; the rail earns its width only for work beside it.
156
160
  const hasTasks = tasks.some((entry) => entry.kind !== 'main_thread');
157
- // #3288 §1: the entry open in the detail sheet, resolved fresh from the live snapshot each render
161
+ // The entry open in the detail sheet, resolved fresh from the live snapshot each render
158
162
  // (so it reflects the entry's own updates, e.g. a Stop taking effect) rather than a stale copy.
159
163
  const openEntry = tasks.find((entry) => entry.id === state.openEntryId) ?? null;
160
164
  // A loop's `cancel` means "stop the loop" (`/loop stop <id>`) — never cancel-background-task,
@@ -167,9 +171,9 @@ export function SessionSurface({
167
171
  state.send({ type: 'cancel-background-task', taskId: entry.sourceId });
168
172
  }
169
173
  };
170
- // #3282 §4 part b-3: the rail also earns its width for a schedule or a goal, even with no running
171
- // background task/loop beside the conversation — "Work in progress" covers all four (issue #3282
172
- // §4's Expected section), not only execution-workspace entries.
174
+ // The rail also earns its width for a schedule or a goal, even with no running
175
+ // background task/loop beside the conversation — "Work in progress" covers all four, not only
176
+ // execution-workspace entries.
173
177
  const currentGoal = state.sessionStatus?.goal ?? null;
174
178
  const hasWorkInProgress = hasTasks || state.scheduledTasks.length > 0 || currentGoal !== null;
175
179
  const isEmpty =
@@ -177,11 +181,13 @@ export function SessionSurface({
177
181
  !state.streamingText &&
178
182
  !state.isThinking &&
179
183
  state.activeTools.length === 0;
180
- // #3282 §3: no provider is configured — the session is running (a placeholder stands in for the
184
+ // No provider is configured — the session is running (a placeholder stands in for the
181
185
  // real one), but there is nothing to reply with yet.
182
186
  const setupRequired = state.sessionStatus?.setupRequired === true;
187
+ // Switching, starting, renaming and deleting sessions are driver actions: an observer has no sidebar.
183
188
  const hasSessionList =
184
- (state.sessionListing ?? null) !== null || state.sessionsError?.code === 'list_failed';
189
+ !readOnly &&
190
+ ((state.sessionListing ?? null) !== null || state.sessionsError?.code === 'list_failed');
185
191
  const sidebarOpen = hasSessionList && state.sessionSidebarOpen;
186
192
  const usage = personalUsageEnabled && view === 'usage';
187
193
  const project = view === 'project';
@@ -189,8 +195,8 @@ export function SessionSurface({
189
195
  const currentRow = listing?.sessions.find((session) => session.id === listing.currentSessionId);
190
196
  // A rename seen on this page wins; otherwise the host's listing names the current session.
191
197
  const title = state.sessionName ?? (currentRow ? sessionTitle(currentRow) : null);
192
- // #3289 §2 — below `md` the open sidebar is a sheet over the conversation (the shared `Dialog`,
193
- // #3331) rather than a column beside it: a dimmed backdrop, Esc, an outside click or choosing a
198
+ // Below `md` the open sidebar is a sheet over the conversation (the shared `Dialog`)
199
+ // rather than a column beside it: a dimmed backdrop, Esc, an outside click or choosing a
194
200
  // session all close it again, and it traps Tab inside it while open.
195
201
  const isNarrow = useMediaQuery(NARROW_WINDOW_QUERY);
196
202
  const sheetActive = sidebarOpen && isNarrow;
@@ -292,24 +298,28 @@ export function SessionSurface({
292
298
  <div className="min-h-0 flex-1">
293
299
  <PersonalUsageDashboard state={state} />
294
300
  {/* A gated turn waits on this answer, so it shows over whatever view is open. */}
295
- <PermissionPrompt
296
- layout="modal"
297
- prompts={state.pendingPrompts}
298
- onAnswerPermission={state.answerPermission}
299
- onAnswerAsk={state.answerAsk}
300
- ownDriverId={state.ownDriverId}
301
- />
301
+ {readOnly ? null : (
302
+ <PermissionPrompt
303
+ layout="modal"
304
+ prompts={state.pendingPrompts}
305
+ onAnswerPermission={state.answerPermission}
306
+ onAnswerAsk={state.answerAsk}
307
+ ownDriverId={state.ownDriverId}
308
+ />
309
+ )}
302
310
  </div>
303
311
  ) : project ? (
304
312
  <div className="min-h-0 flex-1">
305
313
  <ProjectPanel state={state} onOpenMemoryInEditor={onOpenMemoryInEditor} />
306
- <PermissionPrompt
307
- layout="modal"
308
- prompts={state.pendingPrompts}
309
- onAnswerPermission={state.answerPermission}
310
- onAnswerAsk={state.answerAsk}
311
- ownDriverId={state.ownDriverId}
312
- />
314
+ {readOnly ? null : (
315
+ <PermissionPrompt
316
+ layout="modal"
317
+ prompts={state.pendingPrompts}
318
+ onAnswerPermission={state.answerPermission}
319
+ onAnswerAsk={state.answerAsk}
320
+ ownDriverId={state.ownDriverId}
321
+ />
322
+ )}
313
323
  </div>
314
324
  ) : (
315
325
  <div className="flex min-h-0 flex-1">
@@ -320,7 +330,7 @@ export function SessionSurface({
320
330
  onReconnect={onReconnect}
321
331
  />
322
332
  <div className="min-h-0 flex-1 overflow-hidden">
323
- {setupRequired ? (
333
+ {setupRequired && !readOnly ? (
324
334
  <SetupPanel
325
335
  onSetUp={() => state.send({ type: 'command', name: 'provider', args: 'add' })}
326
336
  disabled={state.status !== 'connected'}
@@ -328,7 +338,7 @@ export function SessionSurface({
328
338
  ) : isEmpty ? (
329
339
  <EmptyState />
330
340
  ) : (
331
- <AppViewHostContext.Provider value={appViewHost}>
341
+ <AppViewHostContext.Provider value={readOnly ? null : appViewHost}>
332
342
  <ConversationView
333
343
  messages={state.messages}
334
344
  activeTools={state.activeTools}
@@ -344,19 +354,32 @@ export function SessionSurface({
344
354
  >
345
355
  <GoalBar
346
356
  status={state.sessionStatus ?? null}
347
- onStop={() => state.send({ type: 'command', name: 'goal', args: 'cancel' })}
348
- />
349
- <PermissionPrompt
350
- layout="dock"
351
- prompts={state.pendingPrompts}
352
- onAnswerPermission={state.answerPermission}
353
- onAnswerAsk={state.answerAsk}
354
- ownDriverId={state.ownDriverId}
355
- onFocusReturn={() => composerRef.current?.focus()}
357
+ onStop={
358
+ readOnly
359
+ ? undefined
360
+ : () => state.send({ type: 'command', name: 'goal', args: 'cancel' })
361
+ }
356
362
  />
357
- {/* #3282 §3: hidden while there is no provider to send to — the setup panel's own
363
+ {readOnly ? null : (
364
+ <PermissionPrompt
365
+ layout="dock"
366
+ prompts={state.pendingPrompts}
367
+ onAnswerPermission={state.answerPermission}
368
+ onAnswerAsk={state.answerAsk}
369
+ ownDriverId={state.ownDriverId}
370
+ onFocusReturn={() => composerRef.current?.focus()}
371
+ />
372
+ )}
373
+ {/* Hidden while there is no provider to send to — the setup panel's own
358
374
  button is the only way in, so nobody types into a composer that goes nowhere. */}
359
- {setupRequired ? null : (
375
+ {readOnly ? (
376
+ <p
377
+ role="status"
378
+ className="rounded-2xl bg-card px-4 py-3 text-center text-[14px] text-muted-foreground"
379
+ >
380
+ Read-only: you are watching this session.
381
+ </p>
382
+ ) : setupRequired ? null : (
360
383
  <Composer
361
384
  ref={composerRef}
362
385
  draftMode={draftMode}
@@ -380,7 +403,7 @@ export function SessionSurface({
380
403
  modelList={state.modelList}
381
404
  onRequestModelList={() => state.requestModelList()}
382
405
  onSilentCommand={(name, args) => state.sendCommandSilently(name, args)}
383
- // #3282 §4b: "Manage providers…" now opens Settings at the Providers & Models
406
+ // "Manage providers…" now opens Settings at the Providers & Models
384
407
  // section, replacing the model menu's former server-driven profile flow.
385
408
  onManageProviders={() => state.openSettings('providers')}
386
409
  onSubmit={(prompt) => {
@@ -409,13 +432,17 @@ export function SessionSurface({
409
432
  selectedEntryId={state.openEntryId ?? undefined}
410
433
  onSelect={(entry) => state.openExecutionDetail(entry.id)}
411
434
  onReturnToConversation={() => state.closeExecutionDetail()}
412
- onStop={stopExecutionEntry}
435
+ onStop={readOnly ? undefined : stopExecutionEntry}
413
436
  schedules={state.scheduledTasks}
414
437
  goal={currentGoal}
415
- onPauseSchedule={state.pauseSchedule}
416
- onResumeSchedule={state.resumeSchedule}
417
- onDeleteSchedule={state.deleteSchedule}
418
- onCancelGoal={() => state.send({ type: 'command', name: 'goal', args: 'cancel' })}
438
+ onPauseSchedule={readOnly ? undefined : state.pauseSchedule}
439
+ onResumeSchedule={readOnly ? undefined : state.resumeSchedule}
440
+ onDeleteSchedule={readOnly ? undefined : state.deleteSchedule}
441
+ onCancelGoal={
442
+ readOnly
443
+ ? undefined
444
+ : () => state.send({ type: 'command', name: 'goal', args: 'cancel' })
445
+ }
419
446
  />
420
447
  </aside>
421
448
  )}
@@ -428,14 +455,14 @@ export function SessionSurface({
428
455
  complete={state.executionDetailComplete}
429
456
  onClose={() => state.closeExecutionDetail()}
430
457
  onLoadMore={() => state.loadMoreExecutionDetail()}
431
- onStop={openEntry ? () => stopExecutionEntry(openEntry) : undefined}
458
+ onStop={openEntry && !readOnly ? () => stopExecutionEntry(openEntry) : undefined}
432
459
  inputState={state.executionDetailInput}
433
460
  mode={state.executionDetailMode}
434
461
  unreadable={state.executionDetailUnreadable}
435
462
  onShowEarlier={state.showEarlierExecutionDetail}
436
463
  onShowLive={state.showLiveExecutionDetail}
437
464
  onSend={
438
- openEntry
465
+ openEntry && !readOnly
439
466
  ? (prompt) =>
440
467
  state.send({
441
468
  type: 'send-background-task',
@@ -445,7 +472,7 @@ export function SessionSurface({
445
472
  : undefined
446
473
  }
447
474
  onContinue={
448
- openEntry?.resumeSessionId
475
+ openEntry?.resumeSessionId && !readOnly
449
476
  ? () => {
450
477
  if (openEntry.resumeSessionId) state.switchSession(openEntry.resumeSessionId);
451
478
  }
@@ -457,8 +484,8 @@ export function SessionSurface({
457
484
  </div>
458
485
 
459
486
  <SessionNotices state={state} />
460
- <SettingsScreen state={state} />
461
- <AgentSwitcherSheet state={state} />
487
+ {readOnly ? null : <SettingsScreen state={state} />}
488
+ {readOnly ? null : <AgentSwitcherSheet state={state} />}
462
489
  <HelpSheet
463
490
  open={state.helpOpen}
464
491
  onClose={() => state.closeHelp()}
@@ -470,7 +497,7 @@ export function SessionSurface({
470
497
 
471
498
  /**
472
499
  * A centered chrome frame for the pre-session (loading) and fatal states — reused by app shells. Its
473
- * content area is this page's `main` landmark (#3289 §3 review): nothing above `CenteredChrome`'s
500
+ * content area is this page's `main` landmark: nothing above `CenteredChrome`'s
474
501
  * three call sites (starting, fatal, the trust question) supplies one, and none of the three ever
475
502
  * render alongside `SessionSurface`'s own. `header` stays a sibling of `main`, not inside it, so it
476
503
  * keeps its own implicit `banner` role.