@robota-sdk/agent-ui-web 3.1.2 → 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.
@@ -106,7 +106,8 @@ function SetupPanel({
106
106
  export function SessionSurface({
107
107
  state,
108
108
  surface,
109
- personalUsageEnabled = false,
109
+ personalUsageEnabled: personalUsageRequested = false,
110
+ draftMode = 'persistent',
110
111
  onReconnect,
111
112
  pickFiles,
112
113
  getPathForFile,
@@ -117,6 +118,8 @@ export function SessionSurface({
117
118
  surface?: string;
118
119
  /** Desktop shells opt in; embedded/session-only surfaces keep their existing chat-only contract. */
119
120
  personalUsageEnabled?: boolean;
121
+ /** Secure desktop discards unsent text and attachment paths when the session closes. */
122
+ draftMode?: 'persistent' | 'volatile';
120
123
  /**
121
124
  * Present only when the host can restart the runtime after the connection is lost for good (issue
122
125
  * #3280 §5, the desktop app); absent in the browser, where the banner says how to reopen it instead.
@@ -135,6 +138,8 @@ export function SessionSurface({
135
138
 
136
139
  appViewSandboxUrl?: string;
137
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';
138
143
  const [view, setView] = useState<TSessionView>('chat');
139
144
  const { identity } = useProductIdentity();
140
145
  const appViewHost = useMemo(
@@ -147,6 +152,8 @@ export function SessionSurface({
147
152
  }),
148
153
  [appViewSandboxUrl, state.readAppView, state.callAppTool, identity.displayName, surface],
149
154
  );
155
+ // The personal usage read is not admitted for an observer.
156
+ const personalUsageEnabled = personalUsageRequested && !readOnly;
150
157
  const composerRef = useRef<IComposerHandle>(null);
151
158
  const tasks = state.executionWorkspace?.entries ?? [];
152
159
  // The main thread alone is this conversation; the rail earns its width only for work beside it.
@@ -177,8 +184,10 @@ export function SessionSurface({
177
184
  // #3282 §3: no provider is configured — the session is running (a placeholder stands in for the
178
185
  // real one), but there is nothing to reply with yet.
179
186
  const setupRequired = state.sessionStatus?.setupRequired === true;
187
+ // Switching, starting, renaming and deleting sessions are driver actions: an observer has no sidebar.
180
188
  const hasSessionList =
181
- (state.sessionListing ?? null) !== null || state.sessionsError?.code === 'list_failed';
189
+ !readOnly &&
190
+ ((state.sessionListing ?? null) !== null || state.sessionsError?.code === 'list_failed');
182
191
  const sidebarOpen = hasSessionList && state.sessionSidebarOpen;
183
192
  const usage = personalUsageEnabled && view === 'usage';
184
193
  const project = view === 'project';
@@ -289,24 +298,28 @@ export function SessionSurface({
289
298
  <div className="min-h-0 flex-1">
290
299
  <PersonalUsageDashboard state={state} />
291
300
  {/* A gated turn waits on this answer, so it shows over whatever view is open. */}
292
- <PermissionPrompt
293
- layout="modal"
294
- prompts={state.pendingPrompts}
295
- onAnswerPermission={state.answerPermission}
296
- onAnswerAsk={state.answerAsk}
297
- ownDriverId={state.ownDriverId}
298
- />
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
+ )}
299
310
  </div>
300
311
  ) : project ? (
301
312
  <div className="min-h-0 flex-1">
302
313
  <ProjectPanel state={state} onOpenMemoryInEditor={onOpenMemoryInEditor} />
303
- <PermissionPrompt
304
- layout="modal"
305
- prompts={state.pendingPrompts}
306
- onAnswerPermission={state.answerPermission}
307
- onAnswerAsk={state.answerAsk}
308
- ownDriverId={state.ownDriverId}
309
- />
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
+ )}
310
323
  </div>
311
324
  ) : (
312
325
  <div className="flex min-h-0 flex-1">
@@ -317,7 +330,7 @@ export function SessionSurface({
317
330
  onReconnect={onReconnect}
318
331
  />
319
332
  <div className="min-h-0 flex-1 overflow-hidden">
320
- {setupRequired ? (
333
+ {setupRequired && !readOnly ? (
321
334
  <SetupPanel
322
335
  onSetUp={() => state.send({ type: 'command', name: 'provider', args: 'add' })}
323
336
  disabled={state.status !== 'connected'}
@@ -325,7 +338,7 @@ export function SessionSurface({
325
338
  ) : isEmpty ? (
326
339
  <EmptyState />
327
340
  ) : (
328
- <AppViewHostContext.Provider value={appViewHost}>
341
+ <AppViewHostContext.Provider value={readOnly ? null : appViewHost}>
329
342
  <ConversationView
330
343
  messages={state.messages}
331
344
  activeTools={state.activeTools}
@@ -341,21 +354,35 @@ export function SessionSurface({
341
354
  >
342
355
  <GoalBar
343
356
  status={state.sessionStatus ?? null}
344
- onStop={() => state.send({ type: 'command', name: 'goal', args: 'cancel' })}
345
- />
346
- <PermissionPrompt
347
- layout="dock"
348
- prompts={state.pendingPrompts}
349
- onAnswerPermission={state.answerPermission}
350
- onAnswerAsk={state.answerAsk}
351
- ownDriverId={state.ownDriverId}
352
- onFocusReturn={() => composerRef.current?.focus()}
357
+ onStop={
358
+ readOnly
359
+ ? undefined
360
+ : () => state.send({ type: 'command', name: 'goal', args: 'cancel' })
361
+ }
353
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
+ )}
354
373
  {/* #3282 §3: hidden while there is no provider to send to — the setup panel's own
355
374
  button is the only way in, so nobody types into a composer that goes nowhere. */}
356
- {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 : (
357
383
  <Composer
358
384
  ref={composerRef}
385
+ draftMode={draftMode}
359
386
  catalog={state.commandCatalog ?? null}
360
387
  status={state.sessionStatus ?? null}
361
388
  connected={state.status === 'connected'}
@@ -405,13 +432,17 @@ export function SessionSurface({
405
432
  selectedEntryId={state.openEntryId ?? undefined}
406
433
  onSelect={(entry) => state.openExecutionDetail(entry.id)}
407
434
  onReturnToConversation={() => state.closeExecutionDetail()}
408
- onStop={stopExecutionEntry}
435
+ onStop={readOnly ? undefined : stopExecutionEntry}
409
436
  schedules={state.scheduledTasks}
410
437
  goal={currentGoal}
411
- onPauseSchedule={state.pauseSchedule}
412
- onResumeSchedule={state.resumeSchedule}
413
- onDeleteSchedule={state.deleteSchedule}
414
- 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
+ }
415
446
  />
416
447
  </aside>
417
448
  )}
@@ -424,15 +455,37 @@ export function SessionSurface({
424
455
  complete={state.executionDetailComplete}
425
456
  onClose={() => state.closeExecutionDetail()}
426
457
  onLoadMore={() => state.loadMoreExecutionDetail()}
427
- onStop={openEntry ? () => stopExecutionEntry(openEntry) : undefined}
458
+ onStop={openEntry && !readOnly ? () => stopExecutionEntry(openEntry) : undefined}
459
+ inputState={state.executionDetailInput}
460
+ mode={state.executionDetailMode}
461
+ unreadable={state.executionDetailUnreadable}
462
+ onShowEarlier={state.showEarlierExecutionDetail}
463
+ onShowLive={state.showLiveExecutionDetail}
464
+ onSend={
465
+ openEntry && !readOnly
466
+ ? (prompt) =>
467
+ state.send({
468
+ type: 'send-background-task',
469
+ taskId: openEntry.sourceId,
470
+ input: { prompt },
471
+ })
472
+ : undefined
473
+ }
474
+ onContinue={
475
+ openEntry?.resumeSessionId && !readOnly
476
+ ? () => {
477
+ if (openEntry.resumeSessionId) state.switchSession(openEntry.resumeSessionId);
478
+ }
479
+ : undefined
480
+ }
428
481
  />
429
482
  </div>
430
483
  )}
431
484
  </div>
432
485
 
433
486
  <SessionNotices state={state} />
434
- <SettingsScreen state={state} />
435
- <AgentSwitcherSheet state={state} />
487
+ {readOnly ? null : <SettingsScreen state={state} />}
488
+ {readOnly ? null : <AgentSwitcherSheet state={state} />}
436
489
  <HelpSheet
437
490
  open={state.helpOpen}
438
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. */
@@ -286,6 +292,11 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
286
292
  executionDetailError: string | null;
287
293
  /** True once the last page read had no further cursor — nothing more to load. */
288
294
  executionDetailComplete: boolean;
295
+ executionDetailInput?: { taskId: string; status: 'pending' | 'sent' | 'failed' };
296
+ executionDetailMode?: 'live' | 'transcript';
297
+ executionDetailUnreadable?: { locked: number; noKeys: number; doesNotOpen: number };
298
+ showEarlierExecutionDetail?: () => void;
299
+ showLiveExecutionDetail?: () => void;
289
300
  openExecutionDetail: (entryId: string) => void;
290
301
  loadMoreExecutionDetail: () => void;
291
302
  closeExecutionDetail: () => void;
@@ -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(
@@ -1,33 +1,29 @@
1
- /**
2
- * #3288 §1: the detail sheet's own correlated request/response state — which entry is open, its
3
- * accumulated transcript records, and whether more can be loaded. Owned independently from chat
4
- * state, the same way `use-personal-usage.ts` owns the usage-report protocol: one `requestId` per
5
- * outstanding read, so a stale reply for an entry the operator already closed (or a page they moved
6
- * past) is dropped rather than clobbering what is now on screen.
7
- */
8
-
9
- import { useCallback, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
10
2
 
11
3
  import type { TClientMessage } from '../client/ws-session-client.js';
12
4
  import type {
5
+ IBackgroundTaskLogUnreadable,
13
6
  IExecutionDetailCursor,
14
7
  IExecutionDetailRecord,
8
+ IExecutionWorkspaceSnapshot,
15
9
  } from '@robota-sdk/agent-interface-execution';
16
10
  import type { TServerMessage } from '@robota-sdk/agent-transport';
17
11
 
18
12
  export type TExecutionDetailStatus = 'idle' | 'loading' | 'ready' | 'error';
13
+ type TDetailMode = 'live' | 'transcript';
19
14
 
20
15
  export interface IExecutionDetailState {
21
- /** The entry currently open in the detail sheet, or null when it is closed. */
22
16
  openEntryId: string | null;
23
17
  executionDetailStatus: TExecutionDetailStatus;
24
- /** Every record read so far for the open entry, oldest first — pages accumulate via loadMoreExecutionDetail. */
25
18
  executionDetailRecords: readonly IExecutionDetailRecord[];
26
19
  executionDetailError: string | null;
27
- /** True once a page came back with no `nextCursor` — nothing more to load. */
28
20
  executionDetailComplete: boolean;
21
+ executionDetailMode: TDetailMode;
22
+ executionDetailUnreadable: IBackgroundTaskLogUnreadable | undefined;
29
23
  openExecutionDetail: (entryId: string) => void;
30
24
  loadMoreExecutionDetail: () => void;
25
+ showEarlierExecutionDetail: () => void;
26
+ showLiveExecutionDetail: () => void;
31
27
  closeExecutionDetail: () => void;
32
28
  }
33
29
 
@@ -39,37 +35,65 @@ function nextRequestId(): string {
39
35
 
40
36
  export function useExecutionDetailState(
41
37
  send: (msg: TClientMessage) => void,
42
- ): IExecutionDetailState & { handleExecutionDetailMessage: (msg: TServerMessage) => boolean } {
38
+ ): IExecutionDetailState & {
39
+ handleExecutionDetailMessage: (msg: TServerMessage) => boolean;
40
+ handleExecutionWorkspace: (snapshot: IExecutionWorkspaceSnapshot) => void;
41
+ } {
43
42
  const [openEntryId, setOpenEntryId] = useState<string | null>(null);
44
43
  const [status, setStatus] = useState<TExecutionDetailStatus>('idle');
45
44
  const [records, setRecords] = useState<readonly IExecutionDetailRecord[]>([]);
46
45
  const [error, setError] = useState<string | null>(null);
47
46
  const [complete, setComplete] = useState(false);
47
+ const [mode, setMode] = useState<TDetailMode>('live');
48
+ const [unreadable, setUnreadable] = useState<IBackgroundTaskLogUnreadable>();
48
49
  const requestIdRef = useRef<string | null>(null);
49
50
  const entryIdRef = useRef<string | null>(null);
51
+ const modeRef = useRef<TDetailMode>('live');
50
52
  const nextCursorRef = useRef<IExecutionDetailCursor | undefined>(undefined);
53
+ const queuedRef = useRef<{ cursor?: IExecutionDetailCursor; refresh: boolean } | null>(null);
54
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
55
+ const lastReadAtRef = useRef(0);
51
56
 
52
57
  const readPage = useCallback(
53
- (entryId: string, cursor?: IExecutionDetailCursor): void => {
58
+ (entryId: string, cursor?: IExecutionDetailCursor, refresh = false): void => {
59
+ if (requestIdRef.current !== null) {
60
+ if (!refresh || queuedRef.current === null || queuedRef.current.refresh) {
61
+ queuedRef.current = { cursor, refresh };
62
+ }
63
+ return;
64
+ }
54
65
  const requestId = nextRequestId();
55
66
  requestIdRef.current = requestId;
56
- entryIdRef.current = entryId;
57
- setStatus('loading');
67
+ lastReadAtRef.current = Date.now();
68
+ if (!refresh) setStatus('loading');
58
69
  setError(null);
59
70
  send({ type: 'read-execution-detail', requestId, entryId, cursor });
60
71
  },
61
72
  [send],
62
73
  );
63
74
 
75
+ const clearPending = useCallback(() => {
76
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
77
+ timerRef.current = null;
78
+ queuedRef.current = null;
79
+ }, []);
80
+ useEffect(() => clearPending, [clearPending]);
81
+
64
82
  const openExecutionDetail = useCallback(
65
83
  (entryId: string): void => {
84
+ clearPending();
85
+ requestIdRef.current = null;
86
+ entryIdRef.current = entryId;
87
+ modeRef.current = 'live';
88
+ setMode('live');
66
89
  setOpenEntryId(entryId);
67
90
  setRecords([]);
91
+ setUnreadable(undefined);
68
92
  setComplete(false);
69
93
  nextCursorRef.current = undefined;
70
94
  readPage(entryId);
71
95
  },
72
- [readPage],
96
+ [clearPending, readPage],
73
97
  );
74
98
 
75
99
  const loadMoreExecutionDetail = useCallback((): void => {
@@ -77,32 +101,103 @@ export function useExecutionDetailState(
77
101
  readPage(openEntryId, nextCursorRef.current);
78
102
  }, [openEntryId, status, complete, readPage]);
79
103
 
104
+ const changeMode = useCallback(
105
+ (next: TDetailMode): void => {
106
+ const entryId = entryIdRef.current;
107
+ if (!entryId) return;
108
+ clearPending();
109
+ modeRef.current = next;
110
+ setMode(next);
111
+ setRecords([]);
112
+ setUnreadable(undefined);
113
+ setComplete(false);
114
+ setStatus('loading');
115
+ nextCursorRef.current = undefined;
116
+ readPage(entryId, next === 'transcript' ? { offset: 0 } : undefined);
117
+ },
118
+ [clearPending, readPage],
119
+ );
120
+
121
+ const showEarlierExecutionDetail = useCallback(() => changeMode('transcript'), [changeMode]);
122
+ const showLiveExecutionDetail = useCallback(() => changeMode('live'), [changeMode]);
123
+
80
124
  const closeExecutionDetail = useCallback((): void => {
125
+ clearPending();
81
126
  setOpenEntryId(null);
82
127
  requestIdRef.current = null;
83
128
  entryIdRef.current = null;
84
129
  setRecords([]);
130
+ setUnreadable(undefined);
85
131
  setStatus('idle');
86
132
  setError(null);
87
133
  setComplete(false);
88
- }, []);
134
+ }, [clearPending]);
135
+
136
+ const scheduleRefresh = useCallback(
137
+ (entryId: string): void => {
138
+ if (timerRef.current !== null) return;
139
+ const delay = Math.max(0, 250 - (Date.now() - lastReadAtRef.current));
140
+ timerRef.current = setTimeout(() => {
141
+ timerRef.current = null;
142
+ if (entryIdRef.current === entryId && modeRef.current === 'live') {
143
+ readPage(entryId, undefined, true);
144
+ }
145
+ }, delay);
146
+ },
147
+ [readPage],
148
+ );
89
149
 
90
- const handleExecutionDetailMessage = useCallback((msg: TServerMessage): boolean => {
91
- if (msg.type !== 'execution_detail' && msg.type !== 'execution_detail_error') return false;
92
- if (requestIdRef.current !== msg.requestId) return true; // A stale/superseded read — ignored.
93
- if (msg.type === 'execution_detail_error') {
94
- setStatus('error');
95
- setError(msg.message);
150
+ const handleExecutionWorkspace = useCallback(
151
+ (snapshot: IExecutionWorkspaceSnapshot): void => {
152
+ const entryId = entryIdRef.current;
153
+ if (!entryId || modeRef.current !== 'live') return;
154
+ const entry = snapshot.entries.find((item) => item.id === entryId);
155
+ if (entry?.resumeSessionId && entry.controls.includes('attach')) scheduleRefresh(entryId);
156
+ },
157
+ [scheduleRefresh],
158
+ );
159
+
160
+ const handleExecutionDetailMessage = useCallback(
161
+ (msg: TServerMessage): boolean => {
162
+ if (msg.type !== 'execution_detail' && msg.type !== 'execution_detail_error') return false;
163
+ if (requestIdRef.current !== msg.requestId) return true;
164
+ requestIdRef.current = null;
165
+ const queued = queuedRef.current;
166
+ queuedRef.current = null;
167
+ if (!queued || queued.refresh) {
168
+ if (msg.type === 'execution_detail_error') {
169
+ setStatus('error');
170
+ setError(msg.message);
171
+ } else {
172
+ setRecords((previous) =>
173
+ msg.page.cursor === undefined || msg.page.cursor.offset === 0
174
+ ? msg.page.records
175
+ : [...previous, ...msg.page.records],
176
+ );
177
+ setUnreadable((previous) => {
178
+ const current = msg.page.unreadable;
179
+ if (msg.page.cursor === undefined || msg.page.cursor.offset === 0) return current;
180
+ if (!previous) return current;
181
+ if (!current) return previous;
182
+ return {
183
+ locked: previous.locked + current.locked,
184
+ noKeys: previous.noKeys + current.noKeys,
185
+ doesNotOpen: previous.doesNotOpen + current.doesNotOpen,
186
+ };
187
+ });
188
+ nextCursorRef.current = msg.page.nextCursor;
189
+ setComplete(msg.page.nextCursor === undefined);
190
+ setStatus('ready');
191
+ }
192
+ }
193
+ if (queued && entryIdRef.current) {
194
+ if (queued.refresh) scheduleRefresh(entryIdRef.current);
195
+ else readPage(entryIdRef.current, queued.cursor);
196
+ }
96
197
  return true;
97
- }
98
- setRecords((previous) =>
99
- msg.page.cursor === undefined ? msg.page.records : [...previous, ...msg.page.records],
100
- );
101
- nextCursorRef.current = msg.page.nextCursor;
102
- setComplete(msg.page.nextCursor === undefined);
103
- setStatus('ready');
104
- return true;
105
- }, []);
198
+ },
199
+ [readPage, scheduleRefresh],
200
+ );
106
201
 
107
202
  return {
108
203
  openEntryId,
@@ -110,9 +205,14 @@ export function useExecutionDetailState(
110
205
  executionDetailRecords: records,
111
206
  executionDetailError: error,
112
207
  executionDetailComplete: complete,
208
+ executionDetailMode: mode,
209
+ executionDetailUnreadable: unreadable,
113
210
  openExecutionDetail,
114
211
  loadMoreExecutionDetail,
212
+ showEarlierExecutionDetail,
213
+ showLiveExecutionDetail,
115
214
  closeExecutionDetail,
116
215
  handleExecutionDetailMessage,
216
+ handleExecutionWorkspace,
117
217
  };
118
218
  }
@@ -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(