@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
@@ -1,11 +1,12 @@
1
1
  /**
2
- * GUI-005 — the transport-neutral session reducer for the GUI presentation layer. Reconstructs conversation
2
+ * The transport-neutral session reducer for the GUI presentation layer. Reconstructs conversation
3
3
  * state from the wire `TServerMessage` stream, independent of HOW the bytes arrive (a `TMakeSessionClient`
4
4
  * factory over `{onMessage, onStatusChange}`). The core is **generic over its status type** (default
5
5
  * `TConnectionStatus`) so a surface with extra connection states (e.g. the browser WebRTC surface) can widen it
6
6
  * WITHOUT this core depending on that surface's types — keeping the dependency direction acyclic.
7
7
  */
8
8
 
9
+ import { isObserverMessageType } from '@robota-sdk/agent-transport';
9
10
  import { useState, useEffect, useRef, useCallback } from 'react';
10
11
 
11
12
  import { excludedCommandMessage, isExcludedCommand } from './excluded-commands.js';
@@ -53,7 +54,7 @@ import type {
53
54
  TPermissionResultValue,
54
55
  } from '@robota-sdk/agent-interface-session';
55
56
  import type { IExecutionWorkspaceSnapshot } from '@robota-sdk/agent-interface-execution';
56
- import type { TServerMessage } from '@robota-sdk/agent-transport';
57
+ import type { TServerMessage, TSessionSurfaceRole } from '@robota-sdk/agent-transport';
57
58
 
58
59
  export type {
59
60
  IActiveTool,
@@ -82,7 +83,7 @@ function nextSilentCommandRequestId(): string {
82
83
  }
83
84
 
84
85
  /**
85
- * #3288: a finished turn's text and tool calls, in the order they actually happened — a running
86
+ * A finished turn's text and tool calls, in the order they actually happened — a running
86
87
  * turn accumulates these alongside (not instead of) the legacy `streamingText`/`activeTools` live
87
88
  * view, and `finishTurn` flushes them as SEPARATE conversation entries in order, instead of one
88
89
  * tools-block followed by one merged text block. Consecutive tool calls with no text between them
@@ -117,7 +118,7 @@ function pushToolStartSegment(segments: TTurnSegment[], tool: IActiveTool): TTur
117
118
  }
118
119
 
119
120
  /**
120
- * #3288: attribute a `tool_end` to its call by executionId first — two same-named parallel calls
121
+ * Attribute a `tool_end` to its call by executionId first — two same-named parallel calls
121
122
  * can finish out of start order, and matching by name+running alone would close whichever running
122
123
  * call of that name is found first, not the one that actually finished. Falls back to name+running
123
124
  * only when the event carries no executionId (legacy fixtures / hosts that predate it).
@@ -188,7 +189,7 @@ function applyToolEndToSegments(segments: TTurnSegment[], state: IToolState): TT
188
189
  }
189
190
 
190
191
  /**
191
- * #3288: the files an Edit/Write call touched during the turn, aggregated by path — the "Changed
192
+ * The files an Edit/Write call touched during the turn, aggregated by path — the "Changed
192
193
  * files" row's data. `diffLines` keeps the LATEST edit's diff (what a click on the file opens); the
193
194
  * +/- counts sum every edit to that path within the turn.
194
195
  */
@@ -220,7 +221,7 @@ function activeToolFromHistoricalState(state: IToolState): IActiveTool {
220
221
  }
221
222
 
222
223
  /**
223
- * #3288 §2: turns the server's chronological `IHistoryDisplaySegment[]` (a reload/reconnect/resume
224
+ * Turns the server's chronological `IHistoryDisplaySegment[]` (a reload/reconnect/resume
224
225
  * replay) into the SAME `TConversationEntry[]` shape a live turn's `finishTurn` produces — reusing its
225
226
  * exact segment-accumulation and "Changed files" logic, so the two paths cannot draw the turn/grouping
226
227
  * boundary differently. A `'text', role: 'user'` segment is both its own entry AND the turn boundary:
@@ -272,6 +273,10 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
272
273
  const [status, setStatus] = useState<TStatus>('disconnected' as TStatus);
273
274
  // The app-view requests read this to refuse at once when no connection exists to answer them.
274
275
  const statusRef = useRef<TStatus>('disconnected' as TStatus);
276
+ // The role the host gave this connection; null until it answers and again once the connection drops.
277
+ const [role, setRole] = useState<TSessionSurfaceRole | null>(null);
278
+ const roleRef = useRef<TSessionSurfaceRole | null>(null);
279
+ const isObserving = useCallback((): boolean => roleRef.current === 'observe', []);
275
280
  const [messages, setMessages] = useState<TConversationEntry[]>([]);
276
281
  const [activeTools, setActiveTools] = useState<IActiveTool[]>([]);
277
282
  const [streamingText, setStreamingText] = useState('');
@@ -284,15 +289,15 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
284
289
  const [sessionName, setSessionName] = useState<string | null>(null);
285
290
  const [sessionNotices, setSessionNotices] = useState<readonly ISessionNotice[]>([]);
286
291
  const [commandCatalog, setCommandCatalog] = useState<TCommandCatalog | null>(null);
287
- // #3282 §4e: `send` (below) reads this on every `command` message to catch an excluded command —
292
+ // `send` (below) reads this on every `command` message to catch an excluded command —
288
293
  // a ref (not the state above) so `send`'s identity never changes as the catalog arrives or is
289
294
  // refreshed, matching every other value a stable callback in this file reads this way.
290
295
  const commandCatalogRef = useRef<TCommandCatalog | null>(null);
291
296
  const [sessionStatus, setSessionStatus] = useState<TSessionStatus | null>(null);
292
- // #3289 §3: learned from the first frame the server sends this connection, so its own messages and
297
+ // Learned from the first frame the server sends this connection, so its own messages and
293
298
  // prompts never carry a "from" label — only ANOTHER driver's do.
294
299
  const [ownDriverId, setOwnDriverId] = useState<TDriverId | null>(null);
295
- // #3282 §4e: `/help` opens the GUI's own Help sheet — it never reaches the session (`send` below
300
+ // `/help` opens the GUI's own Help sheet — it never reaches the session (`send` below
296
301
  // catches it), so there is no round trip and no stale terminal-style text dump to replace.
297
302
  const [helpOpen, setHelpOpen] = useState(false);
298
303
  const openHelp = useCallback((): void => setHelpOpen(true), []);
@@ -302,7 +307,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
302
307
  const streamingTextRef = useRef('');
303
308
  // The tools of the running turn, mirrored so the turn's end can keep them in the conversation.
304
309
  const activeToolsRef = useRef<IActiveTool[]>([]);
305
- // #3288: the running turn's text/tools in the order they happened — see `TTurnSegment` above.
310
+ // The running turn's text/tools in the order they happened — see `TTurnSegment` above.
306
311
  const turnSegmentsRef = useRef<TTurnSegment[]>([]);
307
312
  // A screen this surface's command asked for: it answers the command in place of the command's own
308
313
  // reply — with the "not available" line (`text`), or with nothing when the screen opened (null).
@@ -323,7 +328,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
323
328
  }, []);
324
329
  /**
325
330
  * End the running turn: its text and tool calls stay in the conversation as SEPARATE entries, in
326
- * the order they happened (#3288) — not one tools-block grouped ahead of a single merged reply.
331
+ * the order they happened — not one tools-block grouped ahead of a single merged reply.
327
332
  */
328
333
  const finishTurn = useCallback(
329
334
  (toolStatus: (tool: IActiveTool) => IActiveTool['status']): void => {
@@ -344,7 +349,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
344
349
  appendEntry({ id: segment.id, role: 'assistant', content: segment.text });
345
350
  }
346
351
  }
347
- // #3288: a turn that changed files ends with one compact summary row, after everything else.
352
+ // A turn that changed files ends with one compact summary row, after everything else.
348
353
  const changedFiles = collectChangedFiles(segments);
349
354
  if (changedFiles.length > 0) {
350
355
  appendEntry({ id: nextId(), role: 'changed-files', files: changedFiles });
@@ -354,7 +359,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
354
359
  );
355
360
  // Commands this surface sent whose result has not come back — a screen request pairs with one.
356
361
  const commandsInFlightRef = useRef(0);
357
- // #3282 §2 (part 2): the `requestId`s of commands sent via `sendCommandSilently`, still awaiting
362
+ // The `requestId`s of commands sent via `sendCommandSilently`, still awaiting
358
363
  // their `command_result`. The wire already correlates a command with its reply by `requestId` (see
359
364
  // `TClientMessage`'s `command` / `TServerMessage`'s `command_result` in `@robota-sdk/agent-transport`)
360
365
  // — a plain `send` command and a `sendCommandSilently` command can be outstanding at once, and only
@@ -375,18 +380,21 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
375
380
  const executionDetailInputRef = useRef<string | null>(null);
376
381
  const send = useCallback(
377
382
  (msg: TClientMessage): void => {
383
+ // A frame an observer may not send never leaves, so nothing may start waiting for its reply.
384
+ // The surface's own answers (`/help`, an excluded command) still run below.
385
+ const refused = isObserving() && !isObserverMessageType(msg.type);
378
386
  if (msg.type === 'send-background-task') {
379
- if (executionDetailInputRef.current !== null) return;
387
+ if (refused || executionDetailInputRef.current !== null) return;
380
388
  executionDetailInputRef.current = msg.taskId;
381
389
  setExecutionDetailInput({ taskId: msg.taskId, status: 'pending' });
382
390
  }
383
391
  if (msg.type === 'command') {
384
- // #3282 §4e: `/help` is the GUI's own Help sheet — never sent to the session.
392
+ // `/help` is the GUI's own Help sheet — never sent to the session.
385
393
  if (msg.name === 'help') {
386
394
  setHelpOpen(true);
387
395
  return;
388
396
  }
389
- // #3282 §4e: a command the GUI leaves out on purpose is caught here, before the session ever
397
+ // A command the GUI leaves out on purpose is caught here, before the session ever
390
398
  // sees it — its own plain sentence answers in place of the session's refusal, exactly like a
391
399
  // typed command's own result would (same entry shape, same place in the conversation). The
392
400
  // catalog entry (when known) carries `runner`/`surfaces`, so a client-only command excluded
@@ -403,15 +411,17 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
403
411
  });
404
412
  return;
405
413
  }
414
+ if (refused) return;
406
415
  commandsInFlightRef.current += 1;
407
416
  }
417
+ if (refused) return;
408
418
  if (msg.type === 'switch-session' || msg.type === 'new-session') {
409
419
  sessionChangesInFlightRef.current += 1;
410
420
  if (msg.requestId !== undefined) sessionChangeRequestIdsRef.current.add(msg.requestId);
411
421
  }
412
422
  clientRef.current?.send(msg);
413
423
  },
414
- [appendEntry],
424
+ [appendEntry, isObserving],
415
425
  );
416
426
  /** One session change this surface asked for has been answered. */
417
427
  const settleSessionChange = useCallback((requestId?: string): void => {
@@ -419,7 +429,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
419
429
  if (requestId !== undefined) sessionChangeRequestIdsRef.current.delete(requestId);
420
430
  }, []);
421
431
  /**
422
- * #3282 §2 (part 2): run a command the same way `send` does, but flag it as controls-triggered —
432
+ * Run a command the same way `send` does, but flag it as controls-triggered —
423
433
  * the model/mode/effort pop-up menus use this instead of `send` directly, so their change is
424
434
  * confirmed by the control's own label (via the `get-status` refresh `command_result` already
425
435
  * triggers) rather than a conversation card. A typed `/command` still goes through plain `send`
@@ -428,7 +438,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
428
438
  * `TClientMessage` — giving `command` a `requestId` there would let ANY caller of `send` opt a typed
429
439
  * command into silence, which is exactly what this must not allow.
430
440
  *
431
- * #3282 §4e: `send` also intercepts `help` and an excluded command before either reaches the wire —
441
+ * `send` also intercepts `help` and an excluded command before either reaches the wire —
432
442
  * for `help` that opens the sheet regardless of how it was asked for, but an excluded command's
433
443
  * interception adds a VISIBLE info card, defeating the silence this function promises. Every current
434
444
  * caller (model/mode/effort) names neither, so this is dormant, not exercised — a future caller must
@@ -453,18 +463,18 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
453
463
  const { handleProjectMessage, ...projectPanelState } = useProjectPanelState(send);
454
464
  const isConnected = useCallback((): boolean => statusRef.current === 'connected', []);
455
465
  const { handleAppViewMessage, rejectPendingAppRequests, readAppView, callAppTool } =
456
- useAppViewRequests(send, isConnected);
466
+ useAppViewRequests(send, isConnected, isObserving);
457
467
  const {
458
468
  handleSessionsMessage,
459
469
  canListSessions,
460
470
  markCurrent,
461
471
  armRestore,
462
472
  ...sessionDirectoryState
463
- } = useSessionDirectoryState(send);
473
+ } = useSessionDirectoryState(send, isObserving);
464
474
  const { requestSessions, setSessionSidebarOpen } = sessionDirectoryState;
465
475
  const { handleSettingsMessage, openSettings, refreshSettings, ...settingsState } =
466
476
  useSettingsState(send);
467
- // #3282 §4b: read inside `handleMessage` via a ref, never `settingsState.settingsOpen` directly —
477
+ // Read inside `handleMessage` via a ref, never `settingsState.settingsOpen` directly —
468
478
  // that reactive value would have to sit in `handleMessage`'s own dependency array, which sits in
469
479
  // the connection effect's dependency array below: opening/closing Settings would give
470
480
  // `handleMessage` a new identity each time, tearing down and reopening the WebSocket underneath it.
@@ -486,7 +496,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
486
496
 
487
497
  const handleMessage = useCallback(
488
498
  (msg: TServerMessage): void => {
489
- // ARCH-2164: touching the exhaustive registry here keeps every decoded variant tied to an
499
+ // Touching the exhaustive registry here keeps every decoded variant tied to an
490
500
  // explicit GUI ownership decision, including variants intentionally handled by focused views.
491
501
  void SERVER_MESSAGE_HANDLING[msg.type];
492
502
  if (handleUsageMessage(msg)) return;
@@ -498,14 +508,19 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
498
508
  if (handleSessionsMessage(msg)) return;
499
509
  if (handleSettingsMessage(msg)) return;
500
510
  if (handleAgentDefinitionsMessage(msg)) return;
501
- // #3282 §4 part b-3: exclusively owns `background_tasks` (the Scheduled group's roster); a
511
+ // Exclusively owns `background_tasks` (the Scheduled group's roster); a
502
512
  // schedule write instead rides the generic `command`/`cancel-background-task` messages, so
503
513
  // for THOSE types this always returns false — it only refreshes on top of whatever else those
504
514
  // types already do below, never in place of it.
505
515
  if (handleSchedulesMessage(msg)) return;
506
516
  switch (msg.type) {
517
+ case 'surface_role': {
518
+ roleRef.current = msg.role;
519
+ setRole(msg.role);
520
+ break;
521
+ }
507
522
  case 'messages': {
508
- // #3288 §2: `display` is the server's projection of this same history into tool rows,
523
+ // `display` is the server's projection of this same history into tool rows,
509
524
  // diffs and "Changed files" — a reload/reconnect/resume replay renders THAT. `display` is
510
525
  // absent only for a host that predates it; that host still gets a working (text-only)
511
526
  // transcript, exactly what this branch always did.
@@ -538,7 +553,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
538
553
  const next = streamingTextRef.current + msg.delta;
539
554
  streamingTextRef.current = next;
540
555
  setStreamingText(next);
541
- // #3288: also track WHERE this text sits relative to the turn's tool calls, for finishTurn.
556
+ // Also track WHERE this text sits relative to the turn's tool calls, for finishTurn.
542
557
  turnSegmentsRef.current = appendTextDeltaToSegments(turnSegmentsRef.current, msg.delta);
543
558
  break;
544
559
  }
@@ -555,7 +570,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
555
570
  }
556
571
  case 'tool_end': {
557
572
  const { state } = msg;
558
- // #3288: attribute to the ONE matching running call (executionId-first) — never map over
573
+ // Attribute to the ONE matching running call (executionId-first) — never map over
559
574
  // every running same-named entry, which would close all of them at once.
560
575
  updateActiveTools((prev) => {
561
576
  const idx = findRunningToolIndex(prev, state);
@@ -572,7 +587,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
572
587
  setExecutionWorkspace(msg.snapshot);
573
588
  break;
574
589
  }
575
- // #3288 §1: a Stop that did not take effect (e.g. the task/loop had already finished) is
590
+ // A Stop that did not take effect (e.g. the task/loop had already finished) is
576
591
  // worth telling the operator; a success shows itself through the snapshot update above.
577
592
  case 'background_task_control_result': {
578
593
  if (msg.action === 'send' && executionDetailInputRef.current === msg.taskId) {
@@ -596,7 +611,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
596
611
  }
597
612
  break;
598
613
  }
599
- // #3280 §2: the prompt queued behind a running turn — shown above the composer, editable
614
+ // The prompt queued behind a running turn — shown above the composer, editable
600
615
  // and removable (`cancel-queue`). `pending: null` means nothing waits; the row disappears.
601
616
  case 'pending': {
602
617
  setQueuedPrompt(
@@ -607,12 +622,12 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
607
622
  case 'permission_request':
608
623
  case 'ask_request':
609
624
  case 'prompt_resolved': {
610
- // REMOTE-007/009: the paired owner renders + answers its own prompts (local == remote).
625
+ // The paired owner renders + answers its own prompts (local == remote).
611
626
  setPendingPrompts((prev) => applyPromptEvent(prev, msg));
612
627
  break;
613
628
  }
614
629
  case 'ui_intent': {
615
- // #3189: `/resume` asks for the session picker — the sidebar, when the host lists sessions.
630
+ // `/resume` asks for the session picker — the sidebar, when the host lists sessions.
616
631
  if (guiScreenForUiIntent(msg.event.intent) === 'session-sidebar' && canListSessions()) {
617
632
  setSessionSidebarOpen(true);
618
633
  requestSessions();
@@ -624,7 +639,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
624
639
  }
625
640
  break;
626
641
  }
627
- // #3282 §4a: `/settings` asks for the Settings screen — always available on this surface.
642
+ // `/settings` asks for the Settings screen — always available on this surface.
628
643
  if (guiScreenForUiIntent(msg.event.intent) === 'settings') {
629
644
  openSettings();
630
645
  if (commandsInFlightRef.current > 0) {
@@ -635,7 +650,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
635
650
  }
636
651
  break;
637
652
  }
638
- // #3282 §4 part b-2: `/plugin` asks for the plugin manager — the Settings screen's
653
+ // `/plugin` asks for the plugin manager — the Settings screen's
639
654
  // Plugins section, the way `show-settings` opens the screen itself above.
640
655
  if (guiScreenForUiIntent(msg.event.intent) === 'settings-plugins') {
641
656
  openSettings('plugins');
@@ -647,7 +662,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
647
662
  }
648
663
  break;
649
664
  }
650
- // #3282 §4 part b-3: `/agent` (bare) asks for the switcher — always available on this
665
+ // `/agent` (bare) asks for the switcher — always available on this
651
666
  // surface, unlike the session picker, which needs a host that lists sessions.
652
667
  if (guiScreenForUiIntent(msg.event.intent) === 'agent-switcher') {
653
668
  openAgentSwitcher();
@@ -659,8 +674,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
659
674
  }
660
675
  break;
661
676
  }
662
- // CMD-004 Stage D: a command this surface issued requested a screen the GUI does not have.
663
- // The command's result follows; the unavailable line answers it (TC-05, never silent).
677
+ // A command this surface issued requested a screen the GUI does not have.
678
+ // The command's result follows; the unavailable line answers it (never silent).
664
679
  const unavailable = {
665
680
  name: uiIntentCommandName(msg.event.intent),
666
681
  text: describeUiIntentForGui(msg.event.intent),
@@ -679,14 +694,14 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
679
694
  }
680
695
  break;
681
696
  }
682
- // CMD-004 Stage E: broadcast session events — a rename/clear executed by the HOST (from any
697
+ // Broadcast session events — a rename/clear executed by the HOST (from any
683
698
  // surface, co-driving included) is reflected here; never a silent drop.
684
699
  case 'session_renamed': {
685
700
  setSessionName(msg.event.name);
686
701
  requestSessions();
687
702
  break;
688
703
  }
689
- // #3189: the host made another session current — drop what this one showed, re-read it all.
704
+ // The host made another session current — drop what this one showed, re-read it all.
690
705
  case 'session_switched': {
691
706
  rejectPendingAppRequests('The active session changed.');
692
707
  settleSessionChange();
@@ -747,7 +762,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
747
762
  ]);
748
763
  break;
749
764
  }
750
- // #3189: a new or switch this surface asked for was refused; the reason is the toast, and
765
+ // A new or switch this surface asked for was refused; the reason is the toast, and
751
766
  // commands in flight are untouched. Only the client that asked is told.
752
767
  case 'session_change_failed': {
753
768
  if (
@@ -763,7 +778,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
763
778
  ]);
764
779
  break;
765
780
  }
766
- // #3289 §1: a row's rename landed — refresh the listing so its title (or the live one, if it
781
+ // A row's rename landed — refresh the listing so its title (or the live one, if it
767
782
  // was current) is current everywhere it shows.
768
783
  case 'session_renamed_in_list': {
769
784
  requestSessions();
@@ -776,7 +791,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
776
791
  ]);
777
792
  break;
778
793
  }
779
- // #3289 §1: a row was deleted. If it was this surface's own session, `session_switched`
794
+ // A row was deleted. If it was this surface's own session, `session_switched`
780
795
  // already arrived (the host switches away before answering) and reset the conversation; this
781
796
  // only needs the listing refreshed so the row is gone everywhere it shows.
782
797
  case 'session_deleted': {
@@ -865,7 +880,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
865
880
  send({ type: 'get-status' });
866
881
  send({ type: 'get-commands' });
867
882
  commandsInFlightRef.current = Math.max(0, commandsInFlightRef.current - 1);
868
- // #3282 §4b: a successful `/provider` command run through the raw command path (Add, Edit,
883
+ // A successful `/provider` command run through the raw command path (Add, Edit,
869
884
  // Duplicate — Use/Model/Delete already return a fresh snapshot from their own
870
885
  // `update-settings` reply) changes the "Providers & Models" list; refresh it if the screen
871
886
  // is open, the same way `get-status` above refreshes the model/mode chips. Never touches
@@ -873,13 +888,13 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
873
888
  if (msg.name === 'provider' && msg.success && settingsOpenRef.current) {
874
889
  refreshSettings();
875
890
  }
876
- // #3282 §2 (part 2): identify silence by THIS reply's own `requestId`, not by position or
891
+ // Identify silence by THIS reply's own `requestId`, not by position or
877
892
  // count — see `silentCommandRequestIdsRef` above. No `requestId` (an older host, or a typed
878
893
  // command) always falls through to `silent = false`, never the reverse.
879
894
  const silent =
880
895
  msg.requestId !== undefined && silentCommandRequestIdsRef.current.delete(msg.requestId);
881
- // #3282 §4 part b-3: a selection made in the agent switcher sheet — its own plain
882
- // confirmation line answers it, never a conversation card (issue #3282 §4's decided design).
896
+ // A selection made in the agent switcher sheet — its own plain
897
+ // confirmation line answers it, never a conversation card.
883
898
  if (resolveSwitchResult(msg.requestId, msg)) break;
884
899
  const unavailable = pendingIntentRef.current;
885
900
  pendingIntentRef.current = null;
@@ -898,7 +913,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
898
913
  }
899
914
  // A command that starts a turn (a skill) says nothing itself; the turn is its answer.
900
915
  if (msg.message.trim().length === 0) break;
901
- // #3282 §2 (part 2): a change made through a status control confirms itself through the
916
+ // A change made through a status control confirms itself through the
902
917
  // control's own label (the `get-status` refresh above lands it) — no conversation card. A
903
918
  // failure still needs saying, as a plain notice rather than a card, and the label is left
904
919
  // unchanged since nothing actually applied. A typed command (plain `send`) keeps its card
@@ -924,7 +939,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
924
939
  case 'complete':
925
940
  case 'interrupted': {
926
941
  send({ type: 'get-status' });
927
- // #3280 §2: the turn ending can advance the queue (its next entry now runs) or, if this
942
+ // The turn ending can advance the queue (its next entry now runs) or, if this
928
943
  // was an abort, drop it entirely (`abort()` clears the whole queue) — either way the
929
944
  // composer's queued-message row is stale until this reply refreshes it.
930
945
  send({ type: 'get-pending' });
@@ -984,6 +999,10 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
984
999
  const onStatusChange = (next: TStatus): void => {
985
1000
  statusRef.current = next;
986
1001
  setStatus(next);
1002
+ if (next !== 'connected') {
1003
+ roleRef.current = null;
1004
+ setRole(null);
1005
+ }
987
1006
  // A reply lost with the connection never arrives; the views waiting on one learn that now.
988
1007
  if (next !== 'connected') {
989
1008
  rejectPendingAppRequests();
@@ -1002,9 +1021,11 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
1002
1021
  sessionChangesInFlightRef.current = 0;
1003
1022
  sessionChangeRequestIdsRef.current.clear();
1004
1023
  pendingIntentRef.current = null;
1024
+ // First, so its reply precedes the session listing the reconnect restore reads.
1025
+ client.send({ type: 'get-surface-role' });
1005
1026
  client.send({ type: 'get-commands' });
1006
1027
  client.send({ type: 'get-status' });
1007
- // #3280 §2: a reconnect that lands back on the SAME session (the common case) fires no
1028
+ // A reconnect that lands back on the SAME session (the common case) fires no
1008
1029
  // `session_switched` — its own `get-pending` would be missed — so ask here too, or a stale
1009
1030
  // `queuedPrompt` from before the drop keeps showing (with working Edit/Remove) until the
1010
1031
  // turn it was queued behind happens to end.
@@ -1015,7 +1036,16 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
1015
1036
  requestSchedules();
1016
1037
  }
1017
1038
  };
1018
- const client = makeClient({ onMessage: handleMessage, onStatusChange });
1039
+ const inner = makeClient({ onMessage: handleMessage, onStatusChange });
1040
+ // The host is the authority; this only keeps an observer from sending what the host would refuse.
1041
+ const client: ISessionClientHandle = {
1042
+ connect: () => inner.connect(),
1043
+ disconnect: () => inner.disconnect(),
1044
+ send: (msg) => {
1045
+ if (roleRef.current === 'observe' && !isObserverMessageType(msg.type)) return;
1046
+ inner.send(msg);
1047
+ },
1048
+ };
1019
1049
  clientRef.current = client;
1020
1050
  client.connect();
1021
1051
  return () => {
@@ -1045,6 +1075,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
1045
1075
  commandCatalog,
1046
1076
  sessionStatus,
1047
1077
  ownDriverId,
1078
+ role,
1048
1079
  send,
1049
1080
  sendCommandSilently,
1050
1081
  pendingPrompts,
@@ -1077,7 +1108,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
1077
1108
  export function useWsSession(
1078
1109
  url: string,
1079
1110
  ): IWsSessionState<TConnectionStatus> & { connectionLost: boolean } {
1080
- // Issue #3280 §5: retries gave up — the runtime is not coming back by itself. A presentation layer
1111
+ // Retries gave up — the runtime is not coming back by itself. A presentation layer
1081
1112
  // (e.g. `SessionSurface`) reads this to show a banner instead of a callback-driven screen swap.
1082
1113
  const [connectionLost, setConnectionLost] = useState(false);
1083
1114
  const makeClient = useCallback<TMakeSessionClient<TConnectionStatus>>(
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ export type {
23
23
  TSessionListing,
24
24
  TSessionsError,
25
25
  } from './hooks/session-client-types.js';
26
- // Issue #3280 §5: a host that can restart the runtime calls this before reloading the page, so the
26
+ // A host that can restart the runtime calls this before reloading the page, so the
27
27
  // first session listing after reconnecting can switch back to the session the person was in.
28
28
  export { rememberSessionForRestore } from './hooks/use-session-directory.js';
29
29
 
@@ -46,7 +46,7 @@ export type {
46
46
  export { applyPromptEvent, permissionResponse, askResponse } from './hooks/prompt-state.js';
47
47
  export type { TPendingPrompt } from './hooks/prompt-state.js';
48
48
 
49
- // ── UI-intent (command screen-request) state — CMD-004 Stage D ──
49
+ // ── UI-intent (command screen-request) state — ──
50
50
  export { describeUiIntentForGui, guiScreenForUiIntent } from './hooks/ui-intent-state.js';
51
51
 
52
52
  // ── Presentation components ─────────────────────────────────
@@ -60,12 +60,12 @@ export { SessionSurface, CenteredChrome } from './components/SessionSurface.js';
60
60
  export type { IComposerHandle, IPickedFile } from './components/Composer.js';
61
61
  export { SessionSidebar } from './components/SessionSidebar.js';
62
62
  export { SessionMonitor } from './components/SessionMonitor.js';
63
- // #3282 §4a: the shared modal-shell primitive (also consumed by #3282 §2's mode-change confirmation
64
- // and #3289 §1's session-delete confirmation) and the Settings screen it backs.
63
+ // The shared modal-shell primitive (also consumed by the mode-change confirmation
64
+ // and the session-delete confirmation) and the Settings screen it backs.
65
65
  export { Dialog, ConfirmDialog } from './components/Dialog.js';
66
66
  export type { IDialogProps, IConfirmDialogProps } from './components/Dialog.js';
67
67
  export { SettingsScreen } from './components/SettingsScreen.js';
68
- // #3282 §4e: the Help sheet `/help` opens instead of the terminal-style text list.
68
+ // The Help sheet `/help` opens instead of the terminal-style text list.
69
69
  export { HelpSheet } from './components/HelpSheet.js';
70
70
  // Pieces of a surface's chrome, not roots: render them inside a `agent-ui` scope.
71
71
  export { ProductMark, ProductWordmark } from './components/Brand.js';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * #3282 §4 part b-3 — plain-words presentation for the Agents panel's "Scheduled" group. Never an
2
+ * Plain-words presentation for the Agents panel's "Scheduled" group. Never an
3
3
  * ISO string or a cron expression: a concrete `nextFireAt` is shown in local time as "Today 9:00" /
4
4
  * "Tomorrow 9:00" / a weekday / a date; a handful of common cron shapes are phrased ("Every weekday
5
5
  * at 9:00"); anything else falls back to "Repeats".
@@ -216,7 +216,7 @@
216
216
  }
217
217
 
218
218
  /*
219
- * A code block's own horizontal scrollbar (#3289 §2) stays visible rather than only-on-hover like the
219
+ * A code block's own horizontal scrollbar stays visible rather than only-on-hover like the
220
220
  * rest of the page (`theme.css`'s quiet overlay scrollbar) — a long unwrapped line needs a standing
221
221
  * signal that there is more of it, not one that only appears once the person already found the edge.
222
222
  * `.gui-code-scroll::-webkit-scrollbar-thumb` outranks that page-wide bare `::-webkit-scrollbar-thumb`