@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
@@ -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
 
@@ -34,7 +38,7 @@ export type TCommandCatalog = Omit<Extract<TServerMessage, { type: 'commands' }>
34
38
  export type TSessionStatus = Extract<TServerMessage, { type: 'session_status' }>['status'];
35
39
 
36
40
  /**
37
- * #3282 §2 (part 2): the models a person may switch to, grouped by configured provider profile —
41
+ * The models a person may switch to, grouped by configured provider profile —
38
42
  * what the model control's pop-up menu is built from. `null` until requested (`list-models`).
39
43
  */
40
44
  export type TModelListSnapshot = Omit<
@@ -55,7 +59,7 @@ export type TCurrentSessionUsageReport = Extract<
55
59
  { type: 'usage_report' }
56
60
  >['report'];
57
61
 
58
- /** #3282 §4c — the Project panel's reads. Each result crosses the wire unchanged from the session
62
+ /** The Project panel's reads. Each result crosses the wire unchanged from the session
59
63
  * method's own return value (see `wire-messages.ts`'s doc comment on `project_status`). */
60
64
  export type TProjectStatusRead = Extract<TServerMessage, { type: 'project_status' }>['result'];
61
65
  export type TProjectDiffRead = Extract<TServerMessage, { type: 'project_diff' }>['result'];
@@ -66,7 +70,7 @@ export interface IConversationMessage {
66
70
  role: 'user' | 'assistant';
67
71
  content: string;
68
72
  isStreaming?: boolean;
69
- /** REMOTE-014 E5: the co-driving author of a user turn. */
73
+ /** The co-driving author of a user turn. */
70
74
  author?: string;
71
75
  }
72
76
 
@@ -97,7 +101,7 @@ export interface IChangedFileSummary {
97
101
  diffLines: readonly IDiffLine[];
98
102
  }
99
103
 
100
- /** #3288: a turn that changed files ends with this compact summary row. */
104
+ /** A turn that changed files ends with this compact summary row. */
101
105
  export interface IChangedFilesEntry {
102
106
  id: string;
103
107
  role: 'changed-files';
@@ -113,28 +117,28 @@ export interface IActiveTool {
113
117
  status: 'running' | 'done' | 'error';
114
118
  input?: string;
115
119
  result?: IToolState['result'];
116
- /** #3288: correlates this call across `tool_start`/`tool_end` — how a finished call is matched. */
120
+ /** Correlates this call across `tool_start`/`tool_end` — how a finished call is matched. */
117
121
  executionId?: string;
118
- /** #3288: a unified diff (Edit/Write), built server-side — the same one an approval prompt showed. */
122
+ /** A unified diff (Edit/Write), built server-side — the same one an approval prompt showed. */
119
123
  diffLines?: readonly IDiffLine[];
120
- /** #3288: the file `diffLines` concerns, workspace-relative when the server could make it so. */
124
+ /** The file `diffLines` concerns, workspace-relative when the server could make it so. */
121
125
  diffFile?: string;
122
- /** #3288: the tool's raw result payload (Shell output, Read content, …), already capped server-side. */
126
+ /** The tool's raw result payload (Shell output, Read content, …), already capped server-side. */
123
127
  toolResultData?: string;
124
128
  /** Admitted observations; opaque resource URIs do not authorize a client-side fetch. */
125
129
  toolResultParts?: IToolState['toolResultParts'];
126
- /** #3288: a workspace-relative display form of a path argument, additive to `input`/`firstArg`. */
130
+ /** A workspace-relative display form of a path argument, additive to `input`/`firstArg`. */
127
131
  displayPath?: string;
128
- /** #3288: set when this call is a projected `/command` tool — render "Ran /<commandName>". */
132
+ /** Set when this call is a projected `/command` tool — render "Ran /<commandName>". */
129
133
  commandName?: string;
130
- /** #3288: true for an internal signal tool (e.g. goal-status) — never rendered as a call. */
134
+ /** True for an internal signal tool (e.g. goal-status) — never rendered as a call. */
131
135
  internal?: boolean;
132
136
 
133
137
  appView?: IToolAppViewRef;
134
138
  }
135
139
 
136
140
  /**
137
- * #3280 §2: a prompt the host queued behind the running turn — the server has already taken it, so it
141
+ * A prompt the host queued behind the running turn — the server has already taken it, so it
138
142
  * is not lost, but it will not run until the turn ends. `count` is the TOTAL number waiting (the shown
139
143
  * `text` plus any behind it), matching the wire's `pendingCount`.
140
144
  */
@@ -148,7 +152,7 @@ export interface ISessionNotice {
148
152
  /**
149
153
  * `session-change-refused`: the host refused a new or switch this surface asked for.
150
154
  * `background-task-control-failed`: a Stop in the Agents panel did not take effect.
151
- * `command-failed` (#3282 §2 part 2): a change made through a status control (model/mode/effort)
155
+ * `command-failed`: a change made through a status control (model/mode/effort)
152
156
  * failed — a plain notice rather than a conversation card, since the control's own label is where
153
157
  * a successful change confirms itself and the label stays unchanged when it fails.
154
158
  */
@@ -161,7 +165,7 @@ export interface ISessionNotice {
161
165
  | 'context-compacted';
162
166
  message: string;
163
167
  /**
164
- * A `session-error` classified at the wire boundary (#3289 §3) — present only when the host could
168
+ * A `session-error` classified at the wire boundary — present only when the host could
165
169
  * tell what kind of failure this was. `SessionNotices` maps it to one plain sentence and keeps
166
170
  * `message` behind a "Details" disclosure; absent, it shows `message` as before.
167
171
  */
@@ -191,7 +195,7 @@ export type TMakeSessionClient<TStatus extends string = TConnectionStatus> = (ca
191
195
  export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
192
196
  status: TStatus;
193
197
  /**
194
- * Set by `useWsSession` once its reconnect retries give up (issue #3280 §5): the runtime is not
198
+ * Set by `useWsSession` once its reconnect retries give up: the runtime is not
195
199
  * coming back by itself. Cleared again once `status` reaches `connected`. Undefined for a reducer
196
200
  * with no such concept (e.g. the WebRTC surface, which has its own `failed`/`refused` statuses).
197
201
  */
@@ -204,12 +208,14 @@ 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. */
210
216
  sessionStatus: TSessionStatus | null;
211
217
  /**
212
- * #3282 §2 (part 2): the models the model control's pop-up menu offers. Null until requested — the
218
+ * The models the model control's pop-up menu offers. Null until requested — the
213
219
  * menu asks for it when it opens (or refreshes it) rather than every surface fetching it eagerly.
214
220
  */
215
221
  modelList: TModelListSnapshot | null;
@@ -233,13 +239,13 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
233
239
  setSessionSidebarOpen: (open: boolean) => void;
234
240
  send: (msg: TClientMessage) => void;
235
241
  /**
236
- * #3282 §2 (part 2): run a command the SAME way `send({type:'command', ...})` does, but mark its
242
+ * Run a command the SAME way `send({type:'command', ...})` does, but mark its
237
243
  * outcome as controls-triggered — `useSessionClient` skips the conversation card for a change made
238
244
  * this way (the control's own label confirms it instead), while a typed command keeps its card.
239
245
  */
240
246
  sendCommandSilently: (name: string, args?: string) => void;
241
247
  pendingPrompts: readonly TPendingPrompt[];
242
- /** #3280 §2: the prompt queued behind a running turn, or null when none is queued. */
248
+ /** The prompt queued behind a running turn, or null when none is queued. */
243
249
  queuedPrompt: IQueuedPrompt | null;
244
250
  answerPermission: (id: string, result: TPermissionResultValue) => void;
245
251
  answerAsk: (id: string, response: TActionResponse) => void;
@@ -257,29 +263,29 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
257
263
  requestCurrentSessionUsage: () => void;
258
264
  sessionNotices: readonly ISessionNotice[];
259
265
  dismissSessionNotice: (id: string) => void;
260
- /** #3282 §4a: whether the Settings modal is shown. `/settings` and the gear button both open it. */
266
+ /** Whether the Settings modal is shown. `/settings` and the gear button both open it. */
261
267
  settingsOpen: boolean;
262
268
  settingsStatus: 'idle' | 'loading' | 'ready' | 'error';
263
269
  /** Null until the first `get-settings` reply arrives. */
264
270
  settingsSnapshot: ISettingsSnapshot | null;
265
271
  /** The plain message from the most recent failed read or write, if any. */
266
272
  settingsError: string | null;
267
- /** #3282 §4 part b-2: which section the screen should land on for the open in progress. */
273
+ /** Which section the screen should land on for the open in progress. */
268
274
  settingsInitialSectionId: string | null;
269
275
  /**
270
276
  * Opens the screen and (re-)fetches its snapshot — the "reopen and see the new value" path.
271
277
  * `sectionId` jumps straight to that section (e.g. `/plugin` → `'plugins'`, "Manage providers…"
272
- * (#3282 §2/§4b) → `'providers'`); omitted opens on the screen's own default (General).
278
+ * → `'providers'`); omitted opens on the screen's own default (General).
273
279
  */
274
280
  openSettings: (sectionId?: string) => void;
275
281
  closeSettings: () => void;
276
282
  /** A failed write leaves `settingsSnapshot` at its previous value; `settingsError` names why. */
277
283
  updateSettings: (patch: TSettingsPatch) => void;
278
- /** #3282 §4e: whether the Help sheet is shown. `/help` opens it instead of reaching the session. */
284
+ /** Whether the Help sheet is shown. `/help` opens it instead of reaching the session. */
279
285
  helpOpen: boolean;
280
286
  openHelp: () => void;
281
287
  closeHelp: () => void;
282
- /** #3288 §1: the id of the execution-workspace entry open in the detail sheet, or null when closed. */
288
+ /** The id of the execution-workspace entry open in the detail sheet, or null when closed. */
283
289
  openEntryId: string | null;
284
290
  executionDetailStatus: 'idle' | 'loading' | 'ready' | 'error';
285
291
  executionDetailRecords: readonly IExecutionDetailRecord[];
@@ -294,21 +300,21 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
294
300
  openExecutionDetail: (entryId: string) => void;
295
301
  loadMoreExecutionDetail: () => void;
296
302
  closeExecutionDetail: () => void;
297
- /** #3282 §4c — the Project panel's "Changes" section (git status). */
303
+ /** The Project panel's "Changes" section (git status). */
298
304
  projectStatusState: 'idle' | 'loading' | 'ready' | 'error';
299
305
  projectStatus: TProjectStatusRead | null;
300
306
  requestProjectStatus: () => void;
301
- /** #3282 §4c — the Project panel's "File diff" section, for the file `projectDiffPath` names. */
307
+ /** The Project panel's "File diff" section, for the file `projectDiffPath` names. */
302
308
  projectDiffState: 'idle' | 'loading' | 'ready' | 'error';
303
309
  projectDiffPath: string | null;
304
310
  projectDiff: TProjectDiffRead | null;
305
311
  requestProjectDiff: (path: string) => void;
306
- /** #3282 §4c — the Project panel's "Memory" section. */
312
+ /** The Project panel's "Memory" section. */
307
313
  projectMemoryState: 'idle' | 'loading' | 'ready' | 'error';
308
314
  projectMemory: TProjectMemoryRead | null;
309
315
  requestProjectMemory: () => void;
310
316
  /**
311
- * #3282 §4 part b-3: whether the agent switcher sheet is shown. `/agent` (bare) and this surface's
317
+ * Whether the agent switcher sheet is shown. `/agent` (bare) and this surface's
312
318
  * own control both open it.
313
319
  */
314
320
  agentSwitcherOpen: boolean;
@@ -325,7 +331,7 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
325
331
  /** Runs the same path as `/agent <name>`. */
326
332
  selectAgent: (agentType: string) => void;
327
333
  /**
328
- * #3282 §4 part b-3: the schedules shown in the Agents panel's "Scheduled" group — every
334
+ * The schedules shown in the Agents panel's "Scheduled" group — every
329
335
  * `IBackgroundTaskState` of `kind: 'scheduled'`, refreshed on connect and after a write.
330
336
  */
331
337
  scheduledTasks: readonly IBackgroundTaskState<'scheduled'>[];
@@ -334,7 +340,7 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
334
340
  resumeSchedule: (taskId: string) => void;
335
341
  /**
336
342
  * Permanently stops a schedule — the same `cancel-background-task` write Stop already uses. A
337
- * failure surfaces the same way a failed Stop does (#3288 §1's `background-task-control-failed`
343
+ * failure surfaces the same way a failed Stop does (the `background-task-control-failed`
338
344
  * session notice) — no separate error state here.
339
345
  */
340
346
  deleteSchedule: (taskId: string) => void;
@@ -1,10 +1,10 @@
1
1
  /**
2
- * CMD-004 Stage D — how the GUI surface answers a `ui_intent`.
2
+ * How the GUI surface answers a `ui_intent`.
3
3
  *
4
4
  * The server requester-routes `ui_intent`, so everything that arrives here was asked for by THIS
5
5
  * surface and must be answered visibly. An intent with a GUI screen opens it (the session picker is
6
- * the session sidebar, #3189; settings and the plugin manager both open the Settings modal, #3282
7
- * §4a/§4 part b-2; the agent switcher is a sheet, #3282 §4 part b-3). #3282 §4e: an intent whose
6
+ * the session sidebar; settings and the plugin manager both open the Settings modal; the agent
7
+ * switcher is a sheet). An intent whose
8
8
  * command is on the GUI's exclusion list never reaches here at all (`excluded-commands.ts` catches it
9
9
  * before the session is asked) — this module only answers a screen this HOST cannot back even though
10
10
  * the GUI knows how to render it (the session picker, when the host cannot list sessions), or a kind
@@ -40,8 +40,8 @@ export function guiScreenForUiIntent(
40
40
  ): 'session-sidebar' | 'settings' | 'settings-plugins' | 'agent-switcher' | null {
41
41
  if (intent.type === 'show-session-picker') return 'session-sidebar';
42
42
  if (intent.type === 'show-settings') return 'settings';
43
- // #3282 §4 part b-2: `/plugin` opens the Settings screen's Plugins section, the way #3331 mapped
44
- // `show-settings` — replacing the "not available on this surface" line this intent used to get.
43
+ // `/plugin` opens the Settings screen's Plugins section, the way `show-settings`
44
+ // does.
45
45
  if (intent.type === 'show-plugin-manager') return 'settings-plugins';
46
46
  if (intent.type === 'show-agent-switcher') return 'agent-switcher';
47
47
  return null;
@@ -51,7 +51,7 @@ export function guiScreenForUiIntent(
51
51
  * Describe one intent for this surface: a plain, honest sentence for a screen this HOST cannot back
52
52
  * right now (a session picker with no session list), the `/theme` exclusion this intent defensively
53
53
  * reuses, or an unrecognized future kind. Never called for `show-settings`, `show-plugin-manager` or
54
- * `show-agent-switcher` — all three always have a screen now (#3282 §4a, §4 part b-2, §4 part b-3) —
54
+ * `show-agent-switcher` — all three always have a screen now —
55
55
  * but their cases are left out of this switch rather than kept as dead branches that would claim
56
56
  * otherwise; an unknown wire-level kind (a newer host) still yields an explicit notice naming the raw
57
57
  * kind, so the "never a silent no-op" floor holds for future intents too.
@@ -24,10 +24,10 @@ function nextRequestId(prefix: string): string {
24
24
  }
25
25
 
26
26
  /**
27
- * #3282 §4 part b-3: the agent switcher sheet's own correlated request/response state, the same
27
+ * The agent switcher sheet's own correlated request/response state, the same
28
28
  * shape `useSettingsState` already uses for `get-settings`/`update-settings` — independent from the
29
29
  * chat conversation, because choosing an agent is answered by the sheet's own confirmation line
30
- * ("Default agent: Explore"), never a conversation card (issue #3282 §4's decided design).
30
+ * ("Default agent: Explore"), never a conversation card.
31
31
  *
32
32
  * `openAgentSwitcher` re-fetches every time (`/agent` bare, or reopening after a close), so a change
33
33
  * made elsewhere reaches an open-then-reopened sheet, matching `useSettingsState`'s own reasoning.
@@ -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,7 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
 
3
3
  /**
4
- * Tracks whether `query` currently matches, reactively (#3289 §2 — the narrow-window session sheet
4
+ * Tracks whether `query` currently matches, reactively (the narrow-window session sheet
5
5
  * below `md`). A host without `window.matchMedia` (an older embedding, or a non-browser test) reports
6
6
  * `false` and never subscribes, rather than throwing.
7
7
  */
@@ -13,7 +13,7 @@ function nextRequestId(): string {
13
13
  }
14
14
 
15
15
  /**
16
- * #3282 §2 (part 2) — the model list the model control's pop-up menu is built from, requested on
16
+ * The model list the model control's pop-up menu is built from, requested on
17
17
  * demand (`list-models`) and correlated by `requestId` (mirrors `usePersonalUsageState`'s pattern) so
18
18
  * a reply to an older request — one already superseded by a newer one, e.g. across a reconnect — is
19
19
  * never applied over the list a still-open menu is currently showing.
@@ -25,7 +25,7 @@ function nextRequestId(prefix: string): string {
25
25
  }
26
26
 
27
27
  /**
28
- * #3282 §4c — the Project panel's correlated request/response state (status, one file's diff, project
28
+ * The Project panel's correlated request/response state (status, one file's diff, project
29
29
  * memory), independent from chat state. Mirrors `usePersonalUsageState`'s shape: a status per read, a
30
30
  * `requestId` ref that drops a stale reply (a diff request superseded by clicking another file before
31
31
  * the first answered), and a request function per read.
@@ -10,15 +10,15 @@ type TSchedulesState = Pick<
10
10
  >;
11
11
 
12
12
  /**
13
- * #3282 §4 part b-3: the Agents panel's "Scheduled" group. No `requestId` correlates
13
+ * The Agents panel's "Scheduled" group. No `requestId` correlates
14
14
  * `get-background-tasks`/`background_tasks` (the message carries none — see `wire-messages.ts`), so
15
15
  * this owns that whole message type for the GUI: every reply is a full refresh of the roster,
16
16
  * filtered server-side to `kind: 'scheduled'` (the same tasks `/schedule list` shows).
17
17
  *
18
18
  * Pause/resume send the SAME `command` text `/schedule pause <id>` / `/schedule resume <id>` runs.
19
19
  * Delete sends the existing `cancel-background-task` write — the same one Stop already uses for any
20
- * other background-task kind (#3288/#3332) — since a schedule IS a background task, and cancelling
21
- * one is permanent (distinct from the reversible pause), matching #3282 §4's "Delete…" requirement.
20
+ * other background-task kind — since a schedule IS a background task, and cancelling
21
+ * one is permanent (distinct from the reversible pause).
22
22
  */
23
23
  export function useSchedulesState(send: (msg: TClientMessage) => void): TSchedulesState & {
24
24
  /** Handles `background_tasks` and a `cancel` `background_task_control_result` — both refresh. */
@@ -46,7 +46,7 @@ export function useSchedulesState(send: (msg: TClientMessage) => void): TSchedul
46
46
  if (msg.type === 'background_task_control_result' && msg.action === 'cancel') {
47
47
  // A schedule's Delete — refresh so a successful cancel drops it from the group at once; a
48
48
  // refused one leaves the row exactly as it was (the central reducer's own case already
49
- // turns a failure into a plain-message session notice — #3288 §1 — so this never duplicates it).
49
+ // turns a failure into a plain-message session notice — so this never duplicates it).
50
50
  if (msg.success) requestSchedules();
51
51
  return false; // not exclusively ours — other future consumers may still want this event
52
52
  }
@@ -30,7 +30,7 @@ function keepsSessionsLive(listing: TSessionListing): boolean {
30
30
  }
31
31
 
32
32
  /**
33
- * Issue #3280 §5: a desktop Reconnect reloads the page, which loses every bit of JS state — so the
33
+ * A desktop Reconnect reloads the page, which loses every bit of JS state — so the
34
34
  * session to go back to cannot live in a ref like `lastSwitchedIdRef` below. `sessionStorage` survives
35
35
  * the reload (and only that tab), so it carries the id across it. Namespaced so a page hosting other
36
36
  * state under the same origin does not collide.
@@ -74,14 +74,17 @@ function initialSidebarOpen(): boolean {
74
74
  }
75
75
 
76
76
  /**
77
- * #3189 — the host's session list beside the conversation: requests correlated by id (the latest
77
+ * The host's session list beside the conversation: requests correlated by id (the latest
78
78
  * wins), the start/switch commands, and whether the sidebar is shown.
79
79
  *
80
80
  * A host that keeps sessions live binds each connection to its own session and puts a new connection
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;
@@ -117,7 +120,7 @@ export function useSessionDirectoryState(send: (msg: TClientMessage) => void): T
117
120
  (): void => send({ type: 'new-session', requestId: nextRequestId('session_change') }),
118
121
  [send, browserNamespace],
119
122
  );
120
- // #3289 §1: rename or delete a row in the list — current or not. Both answer on their own
123
+ // Rename or delete a row in the list — current or not. Both answer on their own
121
124
  // correlated reply (`session_renamed_in_list`/`session_deleted`, or a `_failed` counterpart); the
122
125
  // reducer in `useSessionClient` refreshes the listing on success and raises a notice on failure.
123
126
  const renameSessionInList = useCallback(
@@ -135,7 +138,7 @@ export function useSessionDirectoryState(send: (msg: TClientMessage) => void): T
135
138
  const lastSwitchedIdRef = useRef<string | null>(null);
136
139
  const restoreTargetRef = useRef<string | null>(null);
137
140
  const armRestore = useCallback((): void => {
138
- // A remembered id (issue #3280 §5, a desktop Reconnect's reload) wins over the same-page target:
141
+ // A remembered id (a desktop Reconnect's reload) wins over the same-page target:
139
142
  // on a fresh page, `lastSwitchedIdRef` is always null anyway, so this only ever adds a source.
140
143
  restoreTargetRef.current = readStoredRestoreId(browserNamespace) ?? lastSwitchedIdRef.current;
141
144
  }, [browserNamespace]);
@@ -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(
@@ -24,12 +24,12 @@ function nextRequestId(prefix: string): string {
24
24
  }
25
25
 
26
26
  /**
27
- * #3282 §4a: the Settings screen's own correlated request/response state, independent from chat
27
+ * The Settings screen's own correlated request/response state, independent from chat
28
28
  * state — the same shape `usePersonalUsageState` already uses for `get-personal-usage-report`.
29
29
  *
30
30
  * `openSettings` re-fetches every time (opening from the gear, `/settings`, or reopening after a
31
31
  * close): the simplest correct way for a change made elsewhere — another typed command, another
32
- * client — to reach an open-then-reopened screen, per #3282 §4's "pick the simplest correct option."
32
+ * client — to reach an open-then-reopened screen.
33
33
  *
34
34
  * A failed UPDATE never touches `settingsSnapshot`, so a control bound to it shows the value it had
35
35
  * before the write — "a failed write leaves the control at its previous value" — while
@@ -39,7 +39,7 @@ function nextRequestId(prefix: string): string {
39
39
  export function useSettingsState(send: (msg: TClientMessage) => void): TSettingsState & {
40
40
  handleSettingsMessage: (msg: TServerMessage) => boolean;
41
41
  /**
42
- * #3282 §4b: re-fetch without touching `settingsOpen`/`settingsInitialSectionId` — for a change
42
+ * Re-fetch without touching `settingsOpen`/`settingsInitialSectionId` — for a change
43
43
  * made elsewhere while the screen is already open (e.g. "Add provider…"'s ask flow finishing).
44
44
  * Unlike `openSettings`, this never resets which section is showing.
45
45
  */
@@ -49,7 +49,7 @@ export function useSettingsState(send: (msg: TClientMessage) => void): TSettings
49
49
  const [settingsStatus, setSettingsStatus] = useState<IWsSessionState['settingsStatus']>('idle');
50
50
  const [settingsSnapshot, setSettingsSnapshot] = useState<ISettingsSnapshot | null>(null);
51
51
  const [settingsError, setSettingsError] = useState<string | null>(null);
52
- // #3282 §4 part b-2 / §4b: which section to land on THIS open — `/plugin` opens straight to
52
+ // Which section to land on THIS open — `/plugin` opens straight to
53
53
  // Plugins, "Manage providers…" straight to Providers & Models; everything else (the gear,
54
54
  // `/settings`) opens on the screen's own default (General).
55
55
  const [settingsInitialSectionId, setSettingsInitialSectionId] = useState<string | null>(null);