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

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.
package/docs/SPEC.md CHANGED
@@ -68,6 +68,11 @@ does not own session lifecycle, conversation history, or agent runtime state.
68
68
  a schedule or a goal alone (no running background task or loop) still opens the rail, and a
69
69
  schedule's Pause/Resume and Delete, and a goal's Cancel, run the same command/control paths their
70
70
  slash commands already use — the panel adds no write this package did not already expose.
71
+ - **The surface follows the host's role.** The session client learns its effective role from the
72
+ host when it connects and, while it observes, sends nothing an observer may not send; the surface
73
+ then offers only what an observer can do: the conversation, its work and the project's reads, with
74
+ no composer, answers, session switching or control that changes the session or its work. The host
75
+ remains the authority; hiding a control is presentation, not enforcement.
71
76
  - The title bar and the page title both name the workspace folder the session works in, once the
72
77
  host has said one; the desktop window follows the page title.
73
78
  - A pending permission or ask prompt is visible whatever view is open, because a gated turn waits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-ui-web",
3
- "version": "3.2.1",
3
+ "version": "3.3.0",
4
4
  "description": "Browser-safe React session presentation, wire reducer, WebSocket client and runtime-host contract for consumer-owned agent renderers.",
5
5
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
6
6
  "type": "module",
@@ -60,11 +60,11 @@
60
60
  "pretendard": "^1.3.9",
61
61
  "react-markdown": "^10.1.0",
62
62
  "remark-gfm": "^4.0.1",
63
- "@robota-sdk/agent-interface-execution": "3.2.1",
64
- "@robota-sdk/agent-interface-session": "3.2.1",
65
- "@robota-sdk/agent-transport": "3.2.1",
66
- "@robota-sdk/agent-interface-command": "3.2.1",
67
- "@robota-sdk/agent-interface-transport": "3.2.1"
63
+ "@robota-sdk/agent-interface-command": "^3.2.2",
64
+ "@robota-sdk/agent-interface-execution": "^3.2.2",
65
+ "@robota-sdk/agent-interface-transport": "^3.2.2",
66
+ "@robota-sdk/agent-interface-session": "^3.2.2",
67
+ "@robota-sdk/agent-transport": "^3.3.0"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "react": ">=18"
@@ -115,7 +115,7 @@ export function AgentActivityPanel({
115
115
  task={task}
116
116
  onPause={onPauseSchedule}
117
117
  onResume={onResumeSchedule}
118
- onRequestDelete={() => setPendingDeleteId(task.id)}
118
+ onRequestDelete={onDeleteSchedule ? () => setPendingDeleteId(task.id) : undefined}
119
119
  />
120
120
  ))}
121
121
  </div>
@@ -231,7 +231,8 @@ function ScheduleCard({
231
231
  task: IBackgroundTaskState<'scheduled'>;
232
232
  onPause?: (taskId: string) => void;
233
233
  onResume?: (taskId: string) => void;
234
- onRequestDelete: () => void;
234
+ /** Absent when the schedule is shown without controls (an observer). */
235
+ onRequestDelete?: () => void;
235
236
  }): React.ReactElement {
236
237
  const isPaused = task.status === 'paused';
237
238
  return (
@@ -242,34 +243,40 @@ function ScheduleCard({
242
243
  <p className="mt-1 text-[13px] text-muted-foreground">
243
244
  {describeScheduleTiming(task)} · {describeScheduleStatus(task)}
244
245
  </p>
245
- <div className="mt-2 flex items-center gap-3">
246
- {isPaused ? (
247
- <button
248
- type="button"
249
- onClick={() => onResume?.(task.id)}
250
- className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
251
- >
252
- <Play size={11} fill="currentColor" aria-hidden="true" />
253
- Resume
254
- </button>
255
- ) : (
256
- <button
257
- type="button"
258
- onClick={() => onPause?.(task.id)}
259
- className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
260
- >
261
- <Pause size={11} fill="currentColor" aria-hidden="true" />
262
- Pause
263
- </button>
264
- )}
265
- <button
266
- type="button"
267
- onClick={onRequestDelete}
268
- className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-destructive hover:bg-destructive/10"
269
- >
270
- Delete…
271
- </button>
272
- </div>
246
+ {onResume || onPause || onRequestDelete ? (
247
+ <div className="mt-2 flex items-center gap-3">
248
+ {isPaused
249
+ ? onResume && (
250
+ <button
251
+ type="button"
252
+ onClick={() => onResume(task.id)}
253
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
254
+ >
255
+ <Play size={11} fill="currentColor" aria-hidden="true" />
256
+ Resume
257
+ </button>
258
+ )
259
+ : onPause && (
260
+ <button
261
+ type="button"
262
+ onClick={() => onPause(task.id)}
263
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
264
+ >
265
+ <Pause size={11} fill="currentColor" aria-hidden="true" />
266
+ Pause
267
+ </button>
268
+ )}
269
+ {onRequestDelete && (
270
+ <button
271
+ type="button"
272
+ onClick={onRequestDelete}
273
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-destructive hover:bg-destructive/10"
274
+ >
275
+ Delete…
276
+ </button>
277
+ )}
278
+ </div>
279
+ ) : null}
273
280
  </div>
274
281
  );
275
282
  }
@@ -293,7 +300,7 @@ function GoalCard({
293
300
  <p className="mt-1.5 pl-[26px] text-[13px] text-muted-foreground">
294
301
  {describeGoalStatus(goal)}
295
302
  </p>
296
- {goal.status === 'active' && (
303
+ {goal.status === 'active' && onCancel && (
297
304
  <div className="mt-2 pl-[26px]">
298
305
  <button
299
306
  type="button"
@@ -690,7 +690,8 @@ export function GoalBar({
690
690
  onStop,
691
691
  }: {
692
692
  status: TSessionStatus | null;
693
- onStop: () => void;
693
+ /** Absent for an observer: the goal is shown without a Stop. */
694
+ onStop?: () => void;
694
695
  }): React.ReactElement | null {
695
696
  const goal = status?.goal;
696
697
  if (!goal || goal.status !== 'active') return null;
@@ -707,15 +708,17 @@ export function GoalBar({
707
708
  <span className="flex-shrink-0 text-[13px] tabular-nums text-subtle">
708
709
  {goal.iterations}/{goal.maxIterations}
709
710
  </span>
710
- <button
711
- type="button"
712
- aria-label="Stop goal"
713
- onClick={onStop}
714
- className="flex flex-shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
715
- >
716
- <Square size={11} fill="currentColor" aria-hidden="true" />
717
- Stop
718
- </button>
711
+ {onStop && (
712
+ <button
713
+ type="button"
714
+ aria-label="Stop goal"
715
+ onClick={onStop}
716
+ className="flex flex-shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
717
+ >
718
+ <Square size={11} fill="currentColor" aria-hidden="true" />
719
+ Stop
720
+ </button>
721
+ )}
719
722
  <span
720
723
  aria-hidden="true"
721
724
  className="absolute bottom-0 left-0 h-[2px] bg-accent/70 transition-[width]"
@@ -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,
@@ -138,6 +138,8 @@ export function SessionSurface({
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,6 +152,8 @@ 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.
@@ -180,8 +184,10 @@ export function SessionSurface({
180
184
  // #3282 §3: 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';
@@ -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
  />
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
+ )}
357
373
  {/* #3282 §3: 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}
@@ -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()}
@@ -89,6 +89,8 @@ export const SERVER_MESSAGE_HANDLING = {
89
89
  background_task_control_result: 'visible-notice',
90
90
  protocol_error: 'visible-notice',
91
91
  resume_gap: 'transport-control',
92
+ // The connection's effective role: the session client holds it and stops sending what an observer may not.
93
+ surface_role: 'reducer-state',
92
94
  // #3282 §4a: the Settings screen's own correlated request/response state (`useSettingsState`).
93
95
  settings: 'reducer-state',
94
96
  settings_error: 'reducer-state',
@@ -16,7 +16,11 @@ import type {
16
16
  IExecutionDetailRecord,
17
17
  IExecutionWorkspaceSnapshot,
18
18
  } from '@robota-sdk/agent-interface-execution';
19
- import type { IWireAgentDefinitionSummary, TServerMessage } from '@robota-sdk/agent-transport';
19
+ import type {
20
+ IWireAgentDefinitionSummary,
21
+ TServerMessage,
22
+ TSessionSurfaceRole,
23
+ } from '@robota-sdk/agent-transport';
20
24
 
21
25
  export type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
22
26
 
@@ -204,6 +208,8 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
204
208
  sessionName: string | null;
205
209
  /** This connection's own driver id, learned from the server's first frame; null until then. */
206
210
  ownDriverId: TDriverId | null;
211
+ /** The role the host gave this connection, learned when it connects; null until the host answers. */
212
+ role: TSessionSurfaceRole | null;
207
213
  /** Null until the session has answered; refreshed after every command. */
208
214
  commandCatalog: TCommandCatalog | null;
209
215
  /** Null until the session has answered; refreshed after every command and turn. */
@@ -20,6 +20,7 @@ function nextRequestId(prefix: string): string {
20
20
  }
21
21
 
22
22
  const CONNECTION_CLOSED = 'The connection to the session closed.';
23
+ const READ_ONLY = 'This session is read-only.';
23
24
 
24
25
  /**
25
26
  * The key a finished call's app view is read and called by, taken from the tool row's `appView`
@@ -53,6 +54,7 @@ function callAppToolMessage(
53
54
  export function useAppViewRequests(
54
55
  send: (msg: TClientMessage) => void,
55
56
  isConnected: () => boolean,
57
+ isObserving: () => boolean,
56
58
  ): {
57
59
  readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
58
60
  callAppTool: (
@@ -92,6 +94,11 @@ export function useAppViewRequests(
92
94
  reject(new Error(signal?.aborted ? 'The app view closed.' : CONNECTION_CLOSED));
93
95
  return;
94
96
  }
97
+ // An observer's request is never sent, so no reply would come.
98
+ if (isObserving()) {
99
+ reject(new Error(READ_ONLY));
100
+ return;
101
+ }
95
102
  const requestId = nextRequestId(`${requestOwner}_${prefix}`);
96
103
  const dispose = (): void => signal?.removeEventListener('abort', abort);
97
104
  const abort = (): void => {
@@ -109,7 +116,7 @@ export function useAppViewRequests(
109
116
  reject(error instanceof Error ? error : new Error(String(error)));
110
117
  }
111
118
  }),
112
- [isConnected, requestOwner, send],
119
+ [isConnected, isObserving, requestOwner, send],
113
120
  );
114
121
 
115
122
  const readAppView = useCallback(
@@ -81,7 +81,10 @@ function initialSidebarOpen(): boolean {
81
81
  * on its primary one. After a reconnect to such a host this surface goes back to the session it was
82
82
  * on; an older host has one session for everyone, so there is nothing to go back to.
83
83
  */
84
- export function useSessionDirectoryState(send: (msg: TClientMessage) => void): TSessionDirectoryState & {
84
+ export function useSessionDirectoryState(
85
+ send: (msg: TClientMessage) => void,
86
+ isObserving: () => boolean,
87
+ ): TSessionDirectoryState & {
85
88
  handleSessionsMessage: (msg: TServerMessage) => boolean;
86
89
  /** Whether the host can list sessions, as far as its last answer says. */
87
90
  canListSessions: () => boolean;
@@ -148,12 +151,14 @@ export function useSessionDirectoryState(send: (msg: TClientMessage) => void): T
148
151
  // launch with no remembered id never reaches here (target stays null), so nothing switches.
149
152
  clearStoredRestoreId(browserNamespace);
150
153
  if (target === listing.currentSessionId) return;
154
+ // An observer never moves the session, so there is nothing to go back to.
155
+ if (isObserving()) return;
151
156
  if (!keepsSessionsLive(listing)) return;
152
157
  // A session the host no longer lists (an empty one it never saved) is nothing to go back to.
153
158
  if (!listing.sessions.some((row) => row.id === target)) return;
154
159
  send({ type: 'switch-session', sessionId: target, requestId: nextRequestId('session_change') });
155
160
  },
156
- [send, browserNamespace],
161
+ [send, browserNamespace, isObserving],
157
162
  );
158
163
 
159
164
  const handleSessionsMessage = useCallback(
@@ -6,6 +6,7 @@
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,
@@ -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('');
@@ -375,8 +380,11 @@ 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
  }
@@ -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 => {
@@ -453,14 +463,14 @@ 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);
@@ -504,6 +514,11 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
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
523
  // #3288 §2: `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
@@ -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,6 +1021,8 @@ 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
1028
  // #3280 §2: a reconnect that lands back on the SAME session (the common case) fires no
@@ -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,