@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.101

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 (81) hide show
  1. package/CHANGELOG.md +557 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +736 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +8525 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +8575 -0
  9. package/dist/node/index.d.cts +736 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +736 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +8525 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +130 -0
  17. package/package.json +99 -3
  18. package/src/app-view/bridge.ts +217 -0
  19. package/src/app-view/call-tool-result.ts +58 -0
  20. package/src/app-view/host-context.ts +37 -0
  21. package/src/app-view/types.ts +12 -0
  22. package/src/client/runtime-host.ts +84 -0
  23. package/src/client/ws-session-client.ts +131 -0
  24. package/src/clipboard.ts +81 -0
  25. package/src/components/AgentActivityPanel.tsx +371 -0
  26. package/src/components/AgentSwitcherSheet.tsx +93 -0
  27. package/src/components/AppView.tsx +147 -0
  28. package/src/components/Brand.tsx +33 -0
  29. package/src/components/Composer.tsx +685 -0
  30. package/src/components/ConversationView.tsx +789 -0
  31. package/src/components/Dialog.tsx +230 -0
  32. package/src/components/DiffLines.tsx +81 -0
  33. package/src/components/ExecutionDetailSheet.tsx +142 -0
  34. package/src/components/HelpSheet.tsx +104 -0
  35. package/src/components/PermissionPrompt.tsx +762 -0
  36. package/src/components/PersonalUsageDashboard.tsx +39 -0
  37. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  38. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  39. package/src/components/PopupMenu.tsx +166 -0
  40. package/src/components/ProjectPanel.tsx +268 -0
  41. package/src/components/SessionMonitor.tsx +180 -0
  42. package/src/components/SessionSidebar.tsx +429 -0
  43. package/src/components/SessionSurface.tsx +482 -0
  44. package/src/components/SessionSurfaceChrome.tsx +319 -0
  45. package/src/components/SettingsFields.tsx +159 -0
  46. package/src/components/SettingsGeneralSection.tsx +49 -0
  47. package/src/components/SettingsMcpSection.tsx +157 -0
  48. package/src/components/SettingsPermissionsSection.tsx +137 -0
  49. package/src/components/SettingsPluginsSection.tsx +165 -0
  50. package/src/components/SettingsProvidersSection.tsx +211 -0
  51. package/src/components/SettingsScreen.tsx +340 -0
  52. package/src/components/StatusControls.tsx +294 -0
  53. package/src/components/composer-attachments.ts +246 -0
  54. package/src/components/personal-usage-dashboard-types.ts +26 -0
  55. package/src/driver-labels.ts +88 -0
  56. package/src/execution-entry-status.ts +55 -0
  57. package/src/goal-view.ts +19 -0
  58. package/src/hooks/command-menu.ts +65 -0
  59. package/src/hooks/excluded-commands.ts +52 -0
  60. package/src/hooks/prompt-state.ts +83 -0
  61. package/src/hooks/server-message-handling.ts +95 -0
  62. package/src/hooks/session-client-types.ts +344 -0
  63. package/src/hooks/ui-intent-state.ts +74 -0
  64. package/src/hooks/use-agent-switcher-state.ts +125 -0
  65. package/src/hooks/use-app-view-requests.ts +178 -0
  66. package/src/hooks/use-execution-detail.ts +118 -0
  67. package/src/hooks/use-media-query.ts +27 -0
  68. package/src/hooks/use-model-list.ts +45 -0
  69. package/src/hooks/use-personal-usage.ts +138 -0
  70. package/src/hooks/use-project-panel.ts +117 -0
  71. package/src/hooks/use-schedules-state.ts +92 -0
  72. package/src/hooks/use-session-directory.ts +202 -0
  73. package/src/hooks/use-settings-state.ts +115 -0
  74. package/src/hooks/useSessionClient.ts +1051 -0
  75. package/src/index.ts +72 -0
  76. package/src/product-identity.tsx +26 -0
  77. package/src/schedule-view.ts +92 -0
  78. package/src/styles/surface.css +231 -0
  79. package/src/styles/surface.css.d.mts +1 -0
  80. package/src/styles/theme.css +38 -0
  81. package/src/styles/theme.css.d.mts +1 -0
@@ -0,0 +1,1051 @@
1
+ /**
2
+ * GUI-005 — the transport-neutral session reducer for the GUI presentation layer. Reconstructs conversation
3
+ * state from the wire `TServerMessage` stream, independent of HOW the bytes arrive (a `TMakeSessionClient`
4
+ * factory over `{onMessage, onStatusChange}`). The core is **generic over its status type** (default
5
+ * `TConnectionStatus`) so a surface with extra connection states (e.g. the browser WebRTC surface) can widen it
6
+ * WITHOUT this core depending on that surface's types — keeping the dependency direction acyclic.
7
+ */
8
+
9
+ import { useState, useEffect, useRef, useCallback } from 'react';
10
+
11
+ import { excludedCommandMessage, isExcludedCommand } from './excluded-commands.js';
12
+ import {
13
+ applyPromptEvent,
14
+ askResponse,
15
+ permissionResponse,
16
+ type TPendingPrompt,
17
+ } from './prompt-state.js';
18
+ import {
19
+ describeUiIntentForGui,
20
+ guiScreenForUiIntent,
21
+ uiIntentCommandName,
22
+ } from './ui-intent-state.js';
23
+ import { createWsSessionClient } from '../client/ws-session-client.js';
24
+ import { SERVER_MESSAGE_HANDLING } from './server-message-handling.js';
25
+ import { useAgentSwitcherState } from './use-agent-switcher-state.js';
26
+ import { useAppViewRequests } from './use-app-view-requests.js';
27
+ import { useExecutionDetailState } from './use-execution-detail.js';
28
+ import { useModelListState } from './use-model-list.js';
29
+ import { usePersonalUsageState } from './use-personal-usage.js';
30
+ import { useProjectPanelState } from './use-project-panel.js';
31
+ import { useSchedulesState } from './use-schedules-state.js';
32
+ import { useSessionDirectoryState } from './use-session-directory.js';
33
+ import { useSettingsState } from './use-settings-state.js';
34
+
35
+ import type {
36
+ IActiveTool,
37
+ IChangedFileSummary,
38
+ IQueuedPrompt,
39
+ ISessionClientHandle,
40
+ ISessionNotice,
41
+ IWsSessionState,
42
+ TCommandCatalog,
43
+ TConversationEntry,
44
+ TMakeSessionClient,
45
+ TSessionStatus,
46
+ } from './session-client-types.js';
47
+ import type { TConnectionStatus, TClientMessage } from '../client/ws-session-client.js';
48
+ import type { TActionResponse } from '@robota-sdk/agent-interface-transport';
49
+ import type {
50
+ IHistoryDisplaySegment,
51
+ IToolState,
52
+ TDriverId,
53
+ TPermissionResultValue,
54
+ } from '@robota-sdk/agent-interface-session';
55
+ import type { IExecutionWorkspaceSnapshot } from '@robota-sdk/agent-interface-execution';
56
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
57
+
58
+ export type {
59
+ IActiveTool,
60
+ IChangedFileSummary,
61
+ IChangedFilesEntry,
62
+ ICommandOutputEntry,
63
+ IConversationMessage,
64
+ IToolGroupEntry,
65
+ IQueuedPrompt,
66
+ ISessionClientHandle,
67
+ ISessionNotice,
68
+ IWsSessionState,
69
+ TConversationEntry,
70
+ TMakeSessionClient,
71
+ } from './session-client-types.js';
72
+
73
+ let msgCounter = 0;
74
+ function nextId(): string {
75
+ return `msg_${++msgCounter}_${Date.now()}`;
76
+ }
77
+
78
+ let silentCommandRequestCounter = 0;
79
+ function nextSilentCommandRequestId(): string {
80
+ silentCommandRequestCounter += 1;
81
+ return `silent_command_${silentCommandRequestCounter}_${Date.now()}`;
82
+ }
83
+
84
+ /**
85
+ * #3288: a finished turn's text and tool calls, in the order they actually happened — a running
86
+ * turn accumulates these alongside (not instead of) the legacy `streamingText`/`activeTools` live
87
+ * view, and `finishTurn` flushes them as SEPARATE conversation entries in order, instead of one
88
+ * tools-block followed by one merged text block. Consecutive tool calls with no text between them
89
+ * collapse into one `tools` segment (one expandable group), matching the TUI's own transcript order.
90
+ */
91
+ interface ITurnTextSegment {
92
+ readonly type: 'text';
93
+ readonly id: string;
94
+ readonly text: string;
95
+ }
96
+ interface ITurnToolsSegment {
97
+ readonly type: 'tools';
98
+ readonly id: string;
99
+ readonly tools: readonly IActiveTool[];
100
+ }
101
+ type TTurnSegment = ITurnTextSegment | ITurnToolsSegment;
102
+
103
+ function appendTextDeltaToSegments(segments: TTurnSegment[], delta: string): TTurnSegment[] {
104
+ const last = segments[segments.length - 1];
105
+ if (last && last.type === 'text') {
106
+ return [...segments.slice(0, -1), { ...last, text: last.text + delta }];
107
+ }
108
+ return [...segments, { type: 'text', id: nextId(), text: delta }];
109
+ }
110
+
111
+ function pushToolStartSegment(segments: TTurnSegment[], tool: IActiveTool): TTurnSegment[] {
112
+ const last = segments[segments.length - 1];
113
+ if (last && last.type === 'tools') {
114
+ return [...segments.slice(0, -1), { ...last, tools: [...last.tools, tool] }];
115
+ }
116
+ return [...segments, { type: 'tools', id: nextId(), tools: [tool] }];
117
+ }
118
+
119
+ /**
120
+ * #3288: attribute a `tool_end` to its call by executionId first — two same-named parallel calls
121
+ * can finish out of start order, and matching by name+running alone would close whichever running
122
+ * call of that name is found first, not the one that actually finished. Falls back to name+running
123
+ * only when the event carries no executionId (legacy fixtures / hosts that predate it).
124
+ */
125
+ function findRunningToolIndex(
126
+ tools: readonly IActiveTool[],
127
+ state: { toolName: string; executionId?: string },
128
+ ): number {
129
+ return state.executionId !== undefined
130
+ ? tools.findIndex((t) => t.executionId === state.executionId && t.status === 'running')
131
+ : tools.findIndex((t) => t.name === state.toolName && t.status === 'running');
132
+ }
133
+
134
+ /**
135
+ * The fields an `IToolState` contributes to a FRESH `IActiveTool` — shared by live `tool_start` (which
136
+ * adds `status: 'running'` itself) and history replay (which merges `toolEndFields` on top, since a
137
+ * replayed call is always already finished). Factored out so the two paths cannot drift on what a
138
+ * tool row's identity fields are.
139
+ */
140
+ function baseActiveToolFrom(state: IToolState): Omit<IActiveTool, 'status'> {
141
+ return {
142
+ id: nextId(),
143
+ name: state.toolName,
144
+ input: state.firstArg,
145
+ ...(state.executionId ? { executionId: state.executionId } : {}),
146
+ ...(state.displayPath ? { displayPath: state.displayPath } : {}),
147
+ ...(state.commandName ? { commandName: state.commandName } : {}),
148
+ ...(state.internal ? { internal: true } : {}),
149
+ };
150
+ }
151
+
152
+ /** The fields a `tool_end` state contributes to the matched `IActiveTool`. */
153
+ function toolEndFields(
154
+ state: IToolState,
155
+ ): Pick<
156
+ IActiveTool,
157
+ 'status' | 'result' | 'diffLines' | 'diffFile' | 'toolResultData' | 'toolResultParts' | 'appView'
158
+ > {
159
+ return {
160
+ // A tool that finished with an error result renders failed (ToolCard/ToolGroup key off
161
+ // `status === 'error'`) — `isRunning` alone cannot tell success from failure.
162
+ status: state.isRunning
163
+ ? 'running'
164
+ : state.result === 'error' || state.result === 'denied'
165
+ ? 'error'
166
+ : 'done',
167
+ result: state.result,
168
+ ...(state.diffLines ? { diffLines: state.diffLines } : {}),
169
+ ...(state.diffFile ? { diffFile: state.diffFile } : {}),
170
+ ...(state.toolResultData !== undefined ? { toolResultData: state.toolResultData } : {}),
171
+ ...(state.toolResultParts ? { toolResultParts: state.toolResultParts } : {}),
172
+ ...(state.appView ? { appView: state.appView } : {}),
173
+ };
174
+ }
175
+
176
+ /** Apply a `tool_end` to the ONE matching running tool across a turn's segments (never all of them). */
177
+ function applyToolEndToSegments(segments: TTurnSegment[], state: IToolState): TTurnSegment[] {
178
+ let matched = false;
179
+ return segments.map((segment) => {
180
+ if (matched || segment.type !== 'tools') return segment;
181
+ const idx = findRunningToolIndex(segment.tools, state);
182
+ if (idx === -1) return segment;
183
+ matched = true;
184
+ const tools = [...segment.tools];
185
+ tools[idx] = { ...tools[idx]!, ...toolEndFields(state) };
186
+ return { ...segment, tools };
187
+ });
188
+ }
189
+
190
+ /**
191
+ * #3288: the files an Edit/Write call touched during the turn, aggregated by path — the "Changed
192
+ * files" row's data. `diffLines` keeps the LATEST edit's diff (what a click on the file opens); the
193
+ * +/- counts sum every edit to that path within the turn.
194
+ */
195
+ function collectChangedFiles(segments: readonly TTurnSegment[]): IChangedFileSummary[] {
196
+ const byPath = new Map<string, IChangedFileSummary>();
197
+ for (const segment of segments) {
198
+ if (segment.type !== 'tools') continue;
199
+ for (const tool of segment.tools) {
200
+ if (!tool.diffFile || !tool.diffLines || tool.diffLines.length === 0) continue;
201
+ const added = tool.diffLines.filter((line) => line.type === 'add').length;
202
+ const removed = tool.diffLines.filter((line) => line.type === 'remove').length;
203
+ const existing = byPath.get(tool.diffFile);
204
+ byPath.set(tool.diffFile, {
205
+ path: tool.diffFile,
206
+ added: (existing?.added ?? 0) + added,
207
+ removed: (existing?.removed ?? 0) + removed,
208
+ diffLines: tool.diffLines,
209
+ });
210
+ }
211
+ }
212
+ return [...byPath.values()];
213
+ }
214
+
215
+ /** A replayed call's `IActiveTool`: `baseActiveToolFrom`'s identity fields plus `tool_end`'s result
216
+ * fields — a replayed call is always already finished, so `toolEndFields` always yields `status`
217
+ * `'done'`/`'error'`, never `'running'`. */
218
+ function activeToolFromHistoricalState(state: IToolState): IActiveTool {
219
+ return { ...baseActiveToolFrom(state), ...toolEndFields(state) };
220
+ }
221
+
222
+ /**
223
+ * #3288 §2: turns the server's chronological `IHistoryDisplaySegment[]` (a reload/reconnect/resume
224
+ * replay) into the SAME `TConversationEntry[]` shape a live turn's `finishTurn` produces — reusing its
225
+ * exact segment-accumulation and "Changed files" logic, so the two paths cannot draw the turn/grouping
226
+ * boundary differently. A `'text', role: 'user'` segment is both its own entry AND the turn boundary:
227
+ * everything accumulated since the previous one (the assistant's reply, its tool calls in between, and
228
+ * one "Changed files" row when any of those touched a file) flushes immediately before it.
229
+ */
230
+ function buildConversationEntriesFromDisplaySegments(
231
+ displaySegments: readonly IHistoryDisplaySegment[],
232
+ ): TConversationEntry[] {
233
+ const entries: TConversationEntry[] = [];
234
+ let turnSegments: TTurnSegment[] = [];
235
+
236
+ const flushTurn = (): void => {
237
+ for (const segment of turnSegments) {
238
+ entries.push(
239
+ segment.type === 'tools'
240
+ ? { id: segment.id, role: 'tools', tools: segment.tools }
241
+ : { id: segment.id, role: 'assistant', content: segment.text },
242
+ );
243
+ }
244
+ const changedFiles = collectChangedFiles(turnSegments);
245
+ if (changedFiles.length > 0) {
246
+ entries.push({ id: nextId(), role: 'changed-files', files: changedFiles });
247
+ }
248
+ turnSegments = [];
249
+ };
250
+
251
+ for (const segment of displaySegments) {
252
+ if (segment.type === 'text' && segment.role === 'user') {
253
+ flushTurn();
254
+ entries.push({ id: nextId(), role: 'user', content: segment.content });
255
+ } else if (segment.type === 'text') {
256
+ turnSegments = appendTextDeltaToSegments(turnSegments, segment.content);
257
+ } else {
258
+ turnSegments = pushToolStartSegment(
259
+ turnSegments,
260
+ activeToolFromHistoricalState(segment.tool),
261
+ );
262
+ }
263
+ }
264
+ flushTurn();
265
+
266
+ return entries;
267
+ }
268
+
269
+ export function useSessionClient<TStatus extends string = TConnectionStatus>(
270
+ makeClient: TMakeSessionClient<TStatus>,
271
+ ): IWsSessionState<TStatus> {
272
+ const [status, setStatus] = useState<TStatus>('disconnected' as TStatus);
273
+ // The app-view requests read this to refuse at once when no connection exists to answer them.
274
+ const statusRef = useRef<TStatus>('disconnected' as TStatus);
275
+ const [messages, setMessages] = useState<TConversationEntry[]>([]);
276
+ const [activeTools, setActiveTools] = useState<IActiveTool[]>([]);
277
+ const [streamingText, setStreamingText] = useState('');
278
+ const [isThinking, setIsThinking] = useState(false);
279
+ const [executionWorkspace, setExecutionWorkspace] = useState<IExecutionWorkspaceSnapshot | null>(
280
+ null,
281
+ );
282
+ const [pendingPrompts, setPendingPrompts] = useState<readonly TPendingPrompt[]>([]);
283
+ const [queuedPrompt, setQueuedPrompt] = useState<IQueuedPrompt | null>(null);
284
+ const [sessionName, setSessionName] = useState<string | null>(null);
285
+ const [sessionNotices, setSessionNotices] = useState<readonly ISessionNotice[]>([]);
286
+ const [commandCatalog, setCommandCatalog] = useState<TCommandCatalog | null>(null);
287
+ // #3282 §4e: `send` (below) reads this on every `command` message to catch an excluded command —
288
+ // a ref (not the state above) so `send`'s identity never changes as the catalog arrives or is
289
+ // refreshed, matching every other value a stable callback in this file reads this way.
290
+ const commandCatalogRef = useRef<TCommandCatalog | null>(null);
291
+ 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
293
+ // prompts never carry a "from" label — only ANOTHER driver's do.
294
+ 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
296
+ // catches it), so there is no round trip and no stale terminal-style text dump to replace.
297
+ const [helpOpen, setHelpOpen] = useState(false);
298
+ const openHelp = useCallback((): void => setHelpOpen(true), []);
299
+ const closeHelp = useCallback((): void => setHelpOpen(false), []);
300
+
301
+ const clientRef = useRef<ISessionClientHandle | null>(null);
302
+ const streamingTextRef = useRef('');
303
+ // The tools of the running turn, mirrored so the turn's end can keep them in the conversation.
304
+ const activeToolsRef = useRef<IActiveTool[]>([]);
305
+ // #3288: the running turn's text/tools in the order they happened — see `TTurnSegment` above.
306
+ const turnSegmentsRef = useRef<TTurnSegment[]>([]);
307
+ // A screen this surface's command asked for: it answers the command in place of the command's own
308
+ // reply — with the "not available" line (`text`), or with nothing when the screen opened (null).
309
+ const pendingIntentRef = useRef<{ name: string; text: string | null } | null>(null);
310
+ // The latest `session_status`, read (not `sessionStatus` state) when a `model_unavailable` notice
311
+ // is created, so the model it names is a snapshot from that moment — never the live status, which
312
+ // `case 'error'` itself just asked the host to refresh and which the person may since have changed.
313
+ const sessionStatusRef = useRef<TSessionStatus | null>(null);
314
+ const updateActiveTools = useCallback(
315
+ (next: (previous: IActiveTool[]) => IActiveTool[]): void => {
316
+ activeToolsRef.current = next(activeToolsRef.current);
317
+ setActiveTools(activeToolsRef.current);
318
+ },
319
+ [],
320
+ );
321
+ const appendEntry = useCallback((entry: TConversationEntry): void => {
322
+ setMessages((previous) => [...previous, entry]);
323
+ }, []);
324
+ /**
325
+ * 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.
327
+ */
328
+ const finishTurn = useCallback(
329
+ (toolStatus: (tool: IActiveTool) => IActiveTool['status']): void => {
330
+ const segments = turnSegmentsRef.current;
331
+ turnSegmentsRef.current = [];
332
+ streamingTextRef.current = '';
333
+ setStreamingText('');
334
+ setIsThinking(false);
335
+ updateActiveTools(() => []);
336
+ for (const segment of segments) {
337
+ if (segment.type === 'tools') {
338
+ appendEntry({
339
+ id: segment.id,
340
+ role: 'tools',
341
+ tools: segment.tools.map((tool) => ({ ...tool, status: toolStatus(tool) })),
342
+ });
343
+ } else {
344
+ appendEntry({ id: segment.id, role: 'assistant', content: segment.text });
345
+ }
346
+ }
347
+ // #3288: a turn that changed files ends with one compact summary row, after everything else.
348
+ const changedFiles = collectChangedFiles(segments);
349
+ if (changedFiles.length > 0) {
350
+ appendEntry({ id: nextId(), role: 'changed-files', files: changedFiles });
351
+ }
352
+ },
353
+ [appendEntry, updateActiveTools],
354
+ );
355
+ // Commands this surface sent whose result has not come back — a screen request pairs with one.
356
+ const commandsInFlightRef = useRef(0);
357
+ // #3282 §2 (part 2): the `requestId`s of commands sent via `sendCommandSilently`, still awaiting
358
+ // their `command_result`. The wire already correlates a command with its reply by `requestId` (see
359
+ // `TClientMessage`'s `command` / `TServerMessage`'s `command_result` in `@robota-sdk/agent-transport`)
360
+ // — a plain `send` command and a `sendCommandSilently` command can be outstanding at once, and only
361
+ // this correlation (not send order, which a slow host or a race can violate) reliably tells which
362
+ // in-flight command a given reply answers. A `command_result` with no `requestId` (an older host, or
363
+ // a typed command, which never sets one) always shows its card — silence is opt-in per request, never
364
+ // the default, so a reply this surface cannot identify is never hidden.
365
+ const silentCommandRequestIdsRef = useRef(new Set<string>());
366
+ // Session changes this surface asked for; a refusal answers one of them, not a command.
367
+ const sessionChangesInFlightRef = useRef(0);
368
+ // Their request ids, for this connection: a refusal names the request it answers. A switch
369
+ // answers with no id, so an id that succeeded stays here until the connection is replaced.
370
+ const sessionChangeRequestIdsRef = useRef(new Set<string>());
371
+ const send = useCallback(
372
+ (msg: TClientMessage): void => {
373
+ if (msg.type === 'command') {
374
+ // #3282 §4e: `/help` is the GUI's own Help sheet — never sent to the session.
375
+ if (msg.name === 'help') {
376
+ setHelpOpen(true);
377
+ return;
378
+ }
379
+ // #3282 §4e: a command the GUI leaves out on purpose is caught here, before the session ever
380
+ // sees it — its own plain sentence answers in place of the session's refusal, exactly like a
381
+ // typed command's own result would (same entry shape, same place in the conversation). The
382
+ // catalog entry (when known) carries `runner`/`surfaces`, so a client-only command excluded
383
+ // only by declaration — not yet given its own curated name below — is still caught here, not
384
+ // just filtered from the `/` menu (`command-menu.ts` checks the same way, catalog in hand).
385
+ const catalogEntry = commandCatalogRef.current?.commands.find((c) => c.name === msg.name);
386
+ if (isExcludedCommand(catalogEntry ?? { name: msg.name })) {
387
+ appendEntry({
388
+ id: nextId(),
389
+ role: 'command',
390
+ name: msg.name,
391
+ content: excludedCommandMessage(msg.name),
392
+ tone: 'info',
393
+ });
394
+ return;
395
+ }
396
+ commandsInFlightRef.current += 1;
397
+ }
398
+ if (msg.type === 'switch-session' || msg.type === 'new-session') {
399
+ sessionChangesInFlightRef.current += 1;
400
+ if (msg.requestId !== undefined) sessionChangeRequestIdsRef.current.add(msg.requestId);
401
+ }
402
+ clientRef.current?.send(msg);
403
+ },
404
+ [appendEntry],
405
+ );
406
+ /** One session change this surface asked for has been answered. */
407
+ const settleSessionChange = useCallback((requestId?: string): void => {
408
+ sessionChangesInFlightRef.current = Math.max(0, sessionChangesInFlightRef.current - 1);
409
+ if (requestId !== undefined) sessionChangeRequestIdsRef.current.delete(requestId);
410
+ }, []);
411
+ /**
412
+ * #3282 §2 (part 2): run a command the same way `send` does, but flag it as controls-triggered —
413
+ * the model/mode/effort pop-up menus use this instead of `send` directly, so their change is
414
+ * confirmed by the control's own label (via the `get-status` refresh `command_result` already
415
+ * triggers) rather than a conversation card. A typed `/command` still goes through plain `send`
416
+ * (and so never carries a `requestId`, keeping it outside this mechanism entirely). The `requestId`
417
+ * is generated here, not read back off `msg`, because `send`'s parameter type is the public
418
+ * `TClientMessage` — giving `command` a `requestId` there would let ANY caller of `send` opt a typed
419
+ * command into silence, which is exactly what this must not allow.
420
+ *
421
+ * #3282 §4e: `send` also intercepts `help` and an excluded command before either reaches the wire —
422
+ * for `help` that opens the sheet regardless of how it was asked for, but an excluded command's
423
+ * interception adds a VISIBLE info card, defeating the silence this function promises. Every current
424
+ * caller (model/mode/effort) names neither, so this is dormant, not exercised — a future caller must
425
+ * not pass one of those two through here.
426
+ */
427
+ const sendCommandSilently = useCallback(
428
+ (name: string, args?: string): void => {
429
+ const requestId = nextSilentCommandRequestId();
430
+ silentCommandRequestIdsRef.current.add(requestId);
431
+ send({ type: 'command', name, requestId, ...(args ? { args } : {}) });
432
+ },
433
+ [send],
434
+ );
435
+ const { handleUsageMessage, ...personalUsageState } = usePersonalUsageState(send);
436
+ const { handleExecutionDetailMessage, closeExecutionDetail, ...executionDetailState } =
437
+ useExecutionDetailState(send);
438
+ const { handleModelListMessage, ...modelListState } = useModelListState(send);
439
+ const { handleProjectMessage, ...projectPanelState } = useProjectPanelState(send);
440
+ const isConnected = useCallback((): boolean => statusRef.current === 'connected', []);
441
+ const { handleAppViewMessage, rejectPendingAppRequests, readAppView, callAppTool } =
442
+ useAppViewRequests(send, isConnected);
443
+ const {
444
+ handleSessionsMessage,
445
+ canListSessions,
446
+ markCurrent,
447
+ armRestore,
448
+ ...sessionDirectoryState
449
+ } = useSessionDirectoryState(send);
450
+ const { requestSessions, setSessionSidebarOpen } = sessionDirectoryState;
451
+ const { handleSettingsMessage, openSettings, refreshSettings, ...settingsState } =
452
+ useSettingsState(send);
453
+ // #3282 §4b: read inside `handleMessage` via a ref, never `settingsState.settingsOpen` directly —
454
+ // that reactive value would have to sit in `handleMessage`'s own dependency array, which sits in
455
+ // the connection effect's dependency array below: opening/closing Settings would give
456
+ // `handleMessage` a new identity each time, tearing down and reopening the WebSocket underneath it.
457
+ const settingsOpenRef = useRef(false);
458
+ settingsOpenRef.current = settingsState.settingsOpen;
459
+ const {
460
+ handleAgentDefinitionsMessage,
461
+ resolveSwitchResult,
462
+ openAgentSwitcher,
463
+ ...agentSwitcherState
464
+ } = useAgentSwitcherState(send);
465
+ const { handleSchedulesMessage, requestSchedules, ...schedulesState } = useSchedulesState(send);
466
+
467
+ const clearCompactionNotices = useCallback((): void => {
468
+ setSessionNotices((previous) => previous.filter((notice) => notice.kind !== 'context-compacted'));
469
+ }, []);
470
+
471
+ const handleMessage = useCallback(
472
+ (msg: TServerMessage): void => {
473
+ // ARCH-2164: touching the exhaustive registry here keeps every decoded variant tied to an
474
+ // explicit GUI ownership decision, including variants intentionally handled by focused views.
475
+ void SERVER_MESSAGE_HANDLING[msg.type];
476
+ if (handleUsageMessage(msg)) return;
477
+ if (handleExecutionDetailMessage(msg)) return;
478
+ if (handleModelListMessage(msg)) return;
479
+ if (handleProjectMessage(msg)) return;
480
+ // A reply to a request an app view's host made (the view's document, or a tool it called).
481
+ if (handleAppViewMessage(msg)) return;
482
+ if (handleSessionsMessage(msg)) return;
483
+ if (handleSettingsMessage(msg)) return;
484
+ if (handleAgentDefinitionsMessage(msg)) return;
485
+ // #3282 §4 part b-3: exclusively owns `background_tasks` (the Scheduled group's roster); a
486
+ // schedule write instead rides the generic `command`/`cancel-background-task` messages, so
487
+ // for THOSE types this always returns false — it only refreshes on top of whatever else those
488
+ // types already do below, never in place of it.
489
+ if (handleSchedulesMessage(msg)) return;
490
+ switch (msg.type) {
491
+ case 'messages': {
492
+ // #3288 §2: `display` is the server's projection of this same history into tool rows,
493
+ // diffs and "Changed files" — a reload/reconnect/resume replay renders THAT. `display` is
494
+ // absent only for a host that predates it; that host still gets a working (text-only)
495
+ // transcript, exactly what this branch always did.
496
+ const reconstructed: TConversationEntry[] = msg.display
497
+ ? buildConversationEntriesFromDisplaySegments(msg.display)
498
+ : msg.messages.flatMap((m) => {
499
+ if (m.role !== 'user' && m.role !== 'assistant') return [];
500
+ const content = m.content ?? '';
501
+ return [{ id: nextId(), role: m.role as 'user' | 'assistant', content }];
502
+ });
503
+ setMessages(reconstructed);
504
+ if (msg.driverId) setOwnDriverId(msg.driverId);
505
+ break;
506
+ }
507
+ case 'user_message': {
508
+ setMessages((prev) => [
509
+ ...prev,
510
+ {
511
+ id: nextId(),
512
+ role: 'user',
513
+ content: msg.content ?? '',
514
+ ...(msg.driverId ? { author: msg.driverId } : {}),
515
+ },
516
+ ]);
517
+ break;
518
+ }
519
+ case 'text_delta': {
520
+ // The ref is the reply's source of truth, updated as each piece arrives: a `complete` in
521
+ // the same batch reads it before React has run a state updater, and would lose the reply.
522
+ const next = streamingTextRef.current + msg.delta;
523
+ streamingTextRef.current = next;
524
+ setStreamingText(next);
525
+ // #3288: also track WHERE this text sits relative to the turn's tool calls, for finishTurn.
526
+ turnSegmentsRef.current = appendTextDeltaToSegments(turnSegmentsRef.current, msg.delta);
527
+ break;
528
+ }
529
+ case 'thinking': {
530
+ setIsThinking(msg.isThinking);
531
+ break;
532
+ }
533
+ case 'tool_start': {
534
+ const { state } = msg;
535
+ const tool: IActiveTool = { ...baseActiveToolFrom(state), status: 'running' };
536
+ updateActiveTools((prev) => [...prev, tool]);
537
+ turnSegmentsRef.current = pushToolStartSegment(turnSegmentsRef.current, tool);
538
+ break;
539
+ }
540
+ case 'tool_end': {
541
+ const { state } = msg;
542
+ // #3288: attribute to the ONE matching running call (executionId-first) — never map over
543
+ // every running same-named entry, which would close all of them at once.
544
+ updateActiveTools((prev) => {
545
+ const idx = findRunningToolIndex(prev, state);
546
+ if (idx === -1) return prev;
547
+ const next = [...prev];
548
+ next[idx] = { ...next[idx]!, ...toolEndFields(state) };
549
+ return next;
550
+ });
551
+ turnSegmentsRef.current = applyToolEndToSegments(turnSegmentsRef.current, state);
552
+ break;
553
+ }
554
+ case 'execution_workspace_event': {
555
+ setExecutionWorkspace(msg.snapshot);
556
+ break;
557
+ }
558
+ // #3288 §1: a Stop that did not take effect (e.g. the task/loop had already finished) is
559
+ // worth telling the operator; a success shows itself through the snapshot update above.
560
+ case 'background_task_control_result': {
561
+ if (!msg.success) {
562
+ setSessionNotices((previous) => [
563
+ ...previous,
564
+ {
565
+ id: nextId(),
566
+ kind: 'background-task-control-failed',
567
+ message: msg.message ?? `Could not stop ${msg.taskId}.`,
568
+ },
569
+ ]);
570
+ }
571
+ break;
572
+ }
573
+ // #3280 §2: the prompt queued behind a running turn — shown above the composer, editable
574
+ // and removable (`cancel-queue`). `pending: null` means nothing waits; the row disappears.
575
+ case 'pending': {
576
+ setQueuedPrompt(
577
+ msg.pending === null ? null : { text: msg.pending, count: msg.pendingCount ?? 1 },
578
+ );
579
+ break;
580
+ }
581
+ case 'permission_request':
582
+ case 'ask_request':
583
+ case 'prompt_resolved': {
584
+ // REMOTE-007/009: the paired owner renders + answers its own prompts (local == remote).
585
+ setPendingPrompts((prev) => applyPromptEvent(prev, msg));
586
+ break;
587
+ }
588
+ case 'ui_intent': {
589
+ // #3189: `/resume` asks for the session picker — the sidebar, when the host lists sessions.
590
+ if (guiScreenForUiIntent(msg.event.intent) === 'session-sidebar' && canListSessions()) {
591
+ setSessionSidebarOpen(true);
592
+ requestSessions();
593
+ if (commandsInFlightRef.current > 0) {
594
+ pendingIntentRef.current = {
595
+ name: uiIntentCommandName(msg.event.intent),
596
+ text: null,
597
+ };
598
+ }
599
+ break;
600
+ }
601
+ // #3282 §4a: `/settings` asks for the Settings screen — always available on this surface.
602
+ if (guiScreenForUiIntent(msg.event.intent) === 'settings') {
603
+ openSettings();
604
+ if (commandsInFlightRef.current > 0) {
605
+ pendingIntentRef.current = {
606
+ name: uiIntentCommandName(msg.event.intent),
607
+ text: null,
608
+ };
609
+ }
610
+ break;
611
+ }
612
+ // #3282 §4 part b-2: `/plugin` asks for the plugin manager — the Settings screen's
613
+ // Plugins section, the way `show-settings` opens the screen itself above.
614
+ if (guiScreenForUiIntent(msg.event.intent) === 'settings-plugins') {
615
+ openSettings('plugins');
616
+ if (commandsInFlightRef.current > 0) {
617
+ pendingIntentRef.current = {
618
+ name: uiIntentCommandName(msg.event.intent),
619
+ text: null,
620
+ };
621
+ }
622
+ break;
623
+ }
624
+ // #3282 §4 part b-3: `/agent` (bare) asks for the switcher — always available on this
625
+ // surface, unlike the session picker, which needs a host that lists sessions.
626
+ if (guiScreenForUiIntent(msg.event.intent) === 'agent-switcher') {
627
+ openAgentSwitcher();
628
+ if (commandsInFlightRef.current > 0) {
629
+ pendingIntentRef.current = {
630
+ name: uiIntentCommandName(msg.event.intent),
631
+ text: null,
632
+ };
633
+ }
634
+ break;
635
+ }
636
+ // CMD-004 Stage D: a command this surface issued requested a screen the GUI does not have.
637
+ // The command's result follows; the unavailable line answers it (TC-05, never silent).
638
+ const unavailable = {
639
+ name: uiIntentCommandName(msg.event.intent),
640
+ text: describeUiIntentForGui(msg.event.intent),
641
+ };
642
+ if (commandsInFlightRef.current > 0) {
643
+ pendingIntentRef.current = unavailable;
644
+ } else {
645
+ // No command of ours awaits a reply (a model-run command, a broadcast): say it now.
646
+ appendEntry({
647
+ id: nextId(),
648
+ role: 'command',
649
+ name: unavailable.name,
650
+ content: unavailable.text,
651
+ tone: 'info',
652
+ });
653
+ }
654
+ break;
655
+ }
656
+ // CMD-004 Stage E: broadcast session events — a rename/clear executed by the HOST (from any
657
+ // surface, co-driving included) is reflected here; never a silent drop.
658
+ case 'session_renamed': {
659
+ setSessionName(msg.event.name);
660
+ requestSessions();
661
+ break;
662
+ }
663
+ // #3189: the host made another session current — drop what this one showed, re-read it all.
664
+ case 'session_switched': {
665
+ rejectPendingAppRequests('The active session changed.');
666
+ settleSessionChange();
667
+ // Nothing of the old session stays on screen until the new one's answers arrive.
668
+ sessionStatusRef.current = null;
669
+ setSessionStatus(null);
670
+ clearCompactionNotices();
671
+ streamingTextRef.current = '';
672
+ turnSegmentsRef.current = [];
673
+ setStreamingText('');
674
+ setIsThinking(false);
675
+ updateActiveTools(() => []);
676
+ setMessages([]);
677
+ setPendingPrompts([]);
678
+ setQueuedPrompt(null);
679
+ setSessionName(null);
680
+ setExecutionWorkspace(null);
681
+ closeExecutionDetail();
682
+ markCurrent(msg.event.sessionId);
683
+ send({ type: 'get-messages' });
684
+ send({ type: 'get-status' });
685
+ send({ type: 'get-commands' });
686
+ send({ type: 'get-execution-workspace' });
687
+ send({ type: 'get-pending' });
688
+ requestSessions();
689
+ requestSchedules();
690
+ break;
691
+ }
692
+ case 'history_cleared': {
693
+ clearCompactionNotices();
694
+ rejectPendingAppRequests('The session history was cleared.');
695
+ streamingTextRef.current = '';
696
+ turnSegmentsRef.current = [];
697
+ setStreamingText('');
698
+ setMessages([]);
699
+ break;
700
+ }
701
+ case 'error': {
702
+ // The `model_unavailable` notice's model is captured NOW, from the wire frame when it
703
+ // named one or else the status this ref already holds — never read live later from
704
+ // render, which would race the `get-status` below (and any model switch after it).
705
+ const model = msg.model ?? sessionStatusRef.current?.model;
706
+ send({ type: 'get-status' });
707
+ finishTurn((tool) => (tool.status === 'running' ? 'error' : tool.status));
708
+ setSessionNotices((previous) => [
709
+ ...previous,
710
+ {
711
+ id: nextId(),
712
+ kind: 'session-error',
713
+ message: msg.message,
714
+ ...(msg.code !== undefined && { code: msg.code }),
715
+ ...(msg.provider !== undefined && { provider: msg.provider }),
716
+ ...(model !== undefined && { model }),
717
+ ...(msg.retryAfterSeconds !== undefined && {
718
+ retryAfterSeconds: msg.retryAfterSeconds,
719
+ }),
720
+ },
721
+ ]);
722
+ break;
723
+ }
724
+ // #3189: a new or switch this surface asked for was refused; the reason is the toast, and
725
+ // commands in flight are untouched. Only the client that asked is told.
726
+ case 'session_change_failed': {
727
+ if (
728
+ msg.requestId !== undefined &&
729
+ !sessionChangeRequestIdsRef.current.has(msg.requestId)
730
+ ) {
731
+ break;
732
+ }
733
+ settleSessionChange(msg.requestId);
734
+ setSessionNotices((previous) => [
735
+ ...previous,
736
+ { id: nextId(), kind: 'session-change-refused', message: msg.message },
737
+ ]);
738
+ break;
739
+ }
740
+ // #3289 §1: a row's rename landed — refresh the listing so its title (or the live one, if it
741
+ // was current) is current everywhere it shows.
742
+ case 'session_renamed_in_list': {
743
+ requestSessions();
744
+ break;
745
+ }
746
+ case 'session_rename_failed': {
747
+ setSessionNotices((previous) => [
748
+ ...previous,
749
+ { id: nextId(), kind: 'session-change-refused', message: msg.message },
750
+ ]);
751
+ break;
752
+ }
753
+ // #3289 §1: a row was deleted. If it was this surface's own session, `session_switched`
754
+ // already arrived (the host switches away before answering) and reset the conversation; this
755
+ // only needs the listing refreshed so the row is gone everywhere it shows.
756
+ case 'session_deleted': {
757
+ requestSessions();
758
+ break;
759
+ }
760
+ case 'session_delete_failed': {
761
+ setSessionNotices((previous) => [
762
+ ...previous,
763
+ { id: nextId(), kind: 'session-change-refused', message: msg.message },
764
+ ]);
765
+ break;
766
+ }
767
+ case 'protocol_error': {
768
+ if (sessionChangesInFlightRef.current > 0) {
769
+ // A host older than `session_change_failed` refuses a session change with this.
770
+ settleSessionChange();
771
+ setSessionNotices((previous) => [
772
+ ...previous,
773
+ { id: nextId(), kind: 'protocol-error', message: msg.message },
774
+ ]);
775
+ break;
776
+ }
777
+ // A command can end in a protocol error instead of a result; a screen it asked for still shows.
778
+ commandsInFlightRef.current = Math.max(0, commandsInFlightRef.current - 1);
779
+ const unavailable = pendingIntentRef.current;
780
+ pendingIntentRef.current = null;
781
+ if (unavailable !== null && unavailable.text !== null) {
782
+ appendEntry({
783
+ id: nextId(),
784
+ role: 'command',
785
+ name: unavailable.name,
786
+ content: unavailable.text,
787
+ tone: 'info',
788
+ });
789
+ }
790
+ setSessionNotices((previous) => [
791
+ ...previous,
792
+ { id: nextId(), kind: 'protocol-error', message: msg.message },
793
+ ]);
794
+ break;
795
+ }
796
+ case 'commands': {
797
+ const catalog = { commands: msg.commands, skills: msg.skills };
798
+ commandCatalogRef.current = catalog;
799
+ setCommandCatalog(catalog);
800
+ break;
801
+ }
802
+ case 'context': {
803
+ // A later status snapshot supplies identity and the latest context when none is known yet.
804
+ if (sessionStatusRef.current !== null) {
805
+ const next = { ...sessionStatusRef.current, context: msg.state };
806
+ sessionStatusRef.current = next;
807
+ setSessionStatus(next);
808
+ }
809
+ break;
810
+ }
811
+ case 'history_changed': {
812
+ const compact = msg.compact;
813
+ if (compact?.trigger === 'auto') {
814
+ setSessionNotices((previous) => [
815
+ ...previous,
816
+ {
817
+ id: nextId(),
818
+ kind: 'context-compacted',
819
+ message: `Auto compacted context: ${Math.round(compact.beforeUsedPercentage)}% → ${Math.round(compact.afterUsedPercentage)}%`,
820
+ },
821
+ ]);
822
+ }
823
+ break;
824
+ }
825
+ case 'session_status': {
826
+ sessionStatusRef.current = msg.status;
827
+ setSessionStatus(msg.status);
828
+ break;
829
+ }
830
+ case 'command_result': {
831
+ // A command may change the status (mode, effort, model) or the catalog (plugins, skills).
832
+ send({ type: 'get-status' });
833
+ send({ type: 'get-commands' });
834
+ commandsInFlightRef.current = Math.max(0, commandsInFlightRef.current - 1);
835
+ // #3282 §4b: a successful `/provider` command run through the raw command path (Add, Edit,
836
+ // Duplicate — Use/Model/Delete already return a fresh snapshot from their own
837
+ // `update-settings` reply) changes the "Providers & Models" list; refresh it if the screen
838
+ // is open, the same way `get-status` above refreshes the model/mode chips. Never touches
839
+ // which section is showing (`refreshSettings`, unlike `openSettings`).
840
+ if (msg.name === 'provider' && msg.success && settingsOpenRef.current) {
841
+ refreshSettings();
842
+ }
843
+ // #3282 §2 (part 2): identify silence by THIS reply's own `requestId`, not by position or
844
+ // count — see `silentCommandRequestIdsRef` above. No `requestId` (an older host, or a typed
845
+ // command) always falls through to `silent = false`, never the reverse.
846
+ const silent =
847
+ msg.requestId !== undefined && silentCommandRequestIdsRef.current.delete(msg.requestId);
848
+ // #3282 §4 part b-3: a selection made in the agent switcher sheet — its own plain
849
+ // confirmation line answers it, never a conversation card (issue #3282 §4's decided design).
850
+ if (resolveSwitchResult(msg.requestId, msg)) break;
851
+ const unavailable = pendingIntentRef.current;
852
+ pendingIntentRef.current = null;
853
+ if (unavailable !== null && msg.success) {
854
+ // The screen that opened is the answer; otherwise the line saying it cannot open is.
855
+ if (unavailable.text !== null) {
856
+ appendEntry({
857
+ id: nextId(),
858
+ role: 'command',
859
+ name: msg.name,
860
+ content: unavailable.text,
861
+ tone: 'info',
862
+ });
863
+ }
864
+ break;
865
+ }
866
+ // A command that starts a turn (a skill) says nothing itself; the turn is its answer.
867
+ if (msg.message.trim().length === 0) break;
868
+ // #3282 §2 (part 2): a change made through a status control confirms itself through the
869
+ // control's own label (the `get-status` refresh above lands it) — no conversation card. A
870
+ // failure still needs saying, as a plain notice rather than a card, and the label is left
871
+ // unchanged since nothing actually applied. A typed command (plain `send`) keeps its card
872
+ // either way — this ONLY silences a change sent via `sendCommandSilently`.
873
+ if (silent) {
874
+ if (!msg.success) {
875
+ setSessionNotices((previous) => [
876
+ ...previous,
877
+ { id: nextId(), kind: 'command-failed', message: msg.message },
878
+ ]);
879
+ }
880
+ break;
881
+ }
882
+ appendEntry({
883
+ id: nextId(),
884
+ role: 'command',
885
+ name: msg.name,
886
+ content: msg.message,
887
+ tone: msg.success ? 'success' : 'error',
888
+ });
889
+ break;
890
+ }
891
+ case 'complete':
892
+ case 'interrupted': {
893
+ send({ type: 'get-status' });
894
+ // #3280 §2: the turn ending can advance the queue (its next entry now runs) or, if this
895
+ // was an abort, drop it entirely (`abort()` clears the whole queue) — either way the
896
+ // composer's queued-message row is stale until this reply refreshes it.
897
+ send({ type: 'get-pending' });
898
+ // The turn changed this session's preview, message count and time in the list.
899
+ requestSessions();
900
+ finishTurn((tool) => (tool.status === 'running' ? 'done' : tool.status));
901
+ break;
902
+ }
903
+ }
904
+ },
905
+ [
906
+ appendEntry,
907
+ canListSessions,
908
+ closeExecutionDetail,
909
+ clearCompactionNotices,
910
+ finishTurn,
911
+ handleAgentDefinitionsMessage,
912
+ handleAppViewMessage,
913
+ handleExecutionDetailMessage,
914
+ handleModelListMessage,
915
+ handleProjectMessage,
916
+ handleSchedulesMessage,
917
+ handleSessionsMessage,
918
+ handleSettingsMessage,
919
+ handleUsageMessage,
920
+ markCurrent,
921
+ openAgentSwitcher,
922
+ openSettings,
923
+ refreshSettings,
924
+ requestSchedules,
925
+ requestSessions,
926
+ resolveSwitchResult,
927
+ rejectPendingAppRequests,
928
+ send,
929
+ setSessionSidebarOpen,
930
+ settleSessionChange,
931
+ updateActiveTools,
932
+ ],
933
+ );
934
+
935
+ const answerPermission = useCallback((id: string, result: TPermissionResultValue): void => {
936
+ clientRef.current?.send(permissionResponse(id, result));
937
+ setPendingPrompts((prev) => prev.filter((p) => p.id !== id)); // optimistic dismiss (prompt_resolved confirms)
938
+ }, []);
939
+
940
+ const answerAsk = useCallback((id: string, response: TActionResponse): void => {
941
+ clientRef.current?.send(askResponse(id, response));
942
+ setPendingPrompts((prev) => prev.filter((p) => p.id !== id));
943
+ }, []);
944
+
945
+ const dismissSessionNotice = useCallback((id: string): void => {
946
+ setSessionNotices((previous) => previous.filter((notice) => notice.id !== id));
947
+ }, []);
948
+
949
+ useEffect(() => {
950
+ const onStatusChange = (next: TStatus): void => {
951
+ statusRef.current = next;
952
+ setStatus(next);
953
+ // A reply lost with the connection never arrives; the views waiting on one learn that now.
954
+ if (next !== 'connected') {
955
+ rejectPendingAppRequests();
956
+ clearCompactionNotices();
957
+ }
958
+ // Every client learns what it can offer and what is in effect as soon as it is attached.
959
+ if (next === 'connected') {
960
+ // A reply lost with the old connection never arrives; stop waiting for it.
961
+ commandsInFlightRef.current = 0;
962
+ silentCommandRequestIdsRef.current.clear();
963
+ sessionChangesInFlightRef.current = 0;
964
+ sessionChangeRequestIdsRef.current.clear();
965
+ pendingIntentRef.current = null;
966
+ client.send({ type: 'get-commands' });
967
+ client.send({ type: 'get-status' });
968
+ // #3280 §2: a reconnect that lands back on the SAME session (the common case) fires no
969
+ // `session_switched` — its own `get-pending` would be missed — so ask here too, or a stale
970
+ // `queuedPrompt` from before the drop keeps showing (with working Edit/Remove) until the
971
+ // turn it was queued behind happens to end.
972
+ client.send({ type: 'get-pending' });
973
+ // A host that keeps sessions live puts a new connection on its primary session.
974
+ armRestore();
975
+ requestSessions();
976
+ requestSchedules();
977
+ }
978
+ };
979
+ const client = makeClient({ onMessage: handleMessage, onStatusChange });
980
+ clientRef.current = client;
981
+ client.connect();
982
+ return () => {
983
+ client.disconnect();
984
+ clientRef.current = null;
985
+ rejectPendingAppRequests();
986
+ };
987
+ }, [
988
+ makeClient,
989
+ handleMessage,
990
+ clearCompactionNotices,
991
+ requestSessions,
992
+ requestSchedules,
993
+ armRestore,
994
+ rejectPendingAppRequests,
995
+ ]);
996
+
997
+ return {
998
+ status,
999
+ messages,
1000
+ activeTools,
1001
+ streamingText,
1002
+ isThinking,
1003
+ executionWorkspace,
1004
+ sessionName,
1005
+ commandCatalog,
1006
+ sessionStatus,
1007
+ ownDriverId,
1008
+ send,
1009
+ sendCommandSilently,
1010
+ pendingPrompts,
1011
+ queuedPrompt,
1012
+ answerPermission,
1013
+ answerAsk,
1014
+ ...personalUsageState,
1015
+ ...executionDetailState,
1016
+ closeExecutionDetail,
1017
+ ...modelListState,
1018
+ ...projectPanelState,
1019
+ ...sessionDirectoryState,
1020
+ sessionNotices,
1021
+ dismissSessionNotice,
1022
+ openSettings,
1023
+ ...settingsState,
1024
+ openAgentSwitcher,
1025
+ ...agentSwitcherState,
1026
+ ...schedulesState,
1027
+ readAppView,
1028
+ callAppTool,
1029
+ helpOpen,
1030
+ openHelp,
1031
+ closeHelp,
1032
+ };
1033
+ }
1034
+
1035
+ /** Connect to a `agent` sidecar over WebSocket (loopback / localhost path). */
1036
+ export function useWsSession(
1037
+ url: string,
1038
+ ): IWsSessionState<TConnectionStatus> & { connectionLost: boolean } {
1039
+ // Issue #3280 §5: retries gave up — the runtime is not coming back by itself. A presentation layer
1040
+ // (e.g. `SessionSurface`) reads this to show a banner instead of a callback-driven screen swap.
1041
+ const [connectionLost, setConnectionLost] = useState(false);
1042
+ const makeClient = useCallback<TMakeSessionClient<TConnectionStatus>>(
1043
+ (cb) => createWsSessionClient(url, { ...cb, onGiveUp: () => setConnectionLost(true) }),
1044
+ [url],
1045
+ );
1046
+ const state = useSessionClient(makeClient);
1047
+ useEffect(() => {
1048
+ if (state.status === 'connected') setConnectionLost(false);
1049
+ }, [state.status]);
1050
+ return { ...state, connectionLost };
1051
+ }