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

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