@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,95 @@
1
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
2
+
3
+ export type TServerMessageHandling =
4
+ 'reducer-state' | 'visible-notice' | 'intentionally-not-rendered' | 'transport-control';
5
+
6
+ /**
7
+ * ARCH-2164: exhaustive ownership for every decoded server variant on the shared GUI surface.
8
+ * Adding a wire variant without deciding its GUI disposition is a compile error instead of a silent
9
+ * reducer omission. `intentionally-not-rendered` is an explicit disposition for server-side query
10
+ * snapshots and domain events that this session/usage surface does not currently visualize.
11
+ */
12
+ export const SERVER_MESSAGE_HANDLING = {
13
+ text_delta: 'reducer-state',
14
+ user_message: 'reducer-state',
15
+ tool_start: 'reducer-state',
16
+ tool_end: 'reducer-state',
17
+ thinking: 'reducer-state',
18
+ complete: 'reducer-state',
19
+ interrupted: 'reducer-state',
20
+ error: 'visible-notice',
21
+ command_result: 'reducer-state',
22
+ messages: 'reducer-state',
23
+ // The full system history serves the TUI; this transcript uses `messages` and streamed turns.
24
+ // A history change carries optional compaction metadata for a notice without replacing the turn.
25
+ history: 'intentionally-not-rendered',
26
+ history_changed: 'visible-notice',
27
+ context: 'reducer-state',
28
+ turn_source: 'intentionally-not-rendered',
29
+ commands: 'reducer-state',
30
+ session_status: 'reducer-state',
31
+ // #3282 §2 part 2: the reply to `list-models`, fed into `modelList` state by the model control's
32
+ // pop-up menu (`useModelListState`).
33
+ model_list: 'reducer-state',
34
+ // #3282 §4 part b-3: the agent switcher sheet's own correlated request/response state (`useAgentSwitcherState`).
35
+ agent_definitions: 'reducer-state',
36
+ sessions: 'reducer-state',
37
+ sessions_error: 'reducer-state',
38
+ session_switched: 'reducer-state',
39
+ session_change_failed: 'visible-notice',
40
+ session_renamed_in_list: 'reducer-state',
41
+ session_rename_failed: 'visible-notice',
42
+ session_deleted: 'reducer-state',
43
+ session_delete_failed: 'visible-notice',
44
+ usage_report: 'reducer-state',
45
+ personal_usage_report: 'reducer-state',
46
+ personal_usage_report_error: 'visible-notice',
47
+ stored_session_usage_report: 'reducer-state',
48
+ stored_session_usage_report_error: 'visible-notice',
49
+ executing: 'intentionally-not-rendered',
50
+ // #3280 §2: the prompt queued behind a running turn — shown above the composer with Edit/Remove.
51
+ pending: 'reducer-state',
52
+ execution_workspace_event: 'reducer-state',
53
+ // #3288 §1: the Agents panel's detail sheet reads a page at a time.
54
+ execution_detail: 'reducer-state',
55
+ execution_detail_error: 'reducer-state',
56
+ // Esc's loop stop is a TUI-only shortcut; this surface's loops stop through the Agents panel
57
+ // instead (`/loop stop <id>`, or `stop-waiting-loop` is simply never sent here).
58
+ waiting_loop_stop: 'intentionally-not-rendered',
59
+ // The Agents panel already reflects a task's cancel/close/send through `execution_workspace_event`;
60
+ // nothing here renders this narrower, task-only event on its own.
61
+ // #3282 §4c: the Project panel's reads — reduced into state by `useProjectPanelState`.
62
+ project_status: 'reducer-state',
63
+ project_diff: 'reducer-state',
64
+ project_memory: 'reducer-state',
65
+
66
+ app_view: 'reducer-state',
67
+ app_tool_result: 'reducer-state',
68
+ background_task_event: 'intentionally-not-rendered',
69
+ background_job_group_event: 'intentionally-not-rendered',
70
+ plan_event: 'intentionally-not-rendered',
71
+ context_file_refreshed: 'intentionally-not-rendered',
72
+ branch_event: 'intentionally-not-rendered',
73
+ // #3282 §4 part b-3: the Agents panel's "Scheduled" group's own roster (`useSchedulesState`).
74
+ background_tasks: 'reducer-state',
75
+ background_task: 'intentionally-not-rendered',
76
+ background_job_groups: 'intentionally-not-rendered',
77
+ background_job_group: 'intentionally-not-rendered',
78
+ background_task_log: 'intentionally-not-rendered',
79
+ permission_request: 'reducer-state',
80
+ ask_request: 'reducer-state',
81
+ prompt_resolved: 'reducer-state',
82
+ ui_intent: 'reducer-state',
83
+ session_renamed: 'reducer-state',
84
+ history_cleared: 'reducer-state',
85
+ // #3288 §1: a failed Stop (e.g. a task that finished a moment before it arrived) is worth telling
86
+ // the operator; a success shows itself through the workspace snapshot's own update. #3282 §4 part
87
+ // b-3: a schedule's Delete uses this same `cancel-background-task` write — `useSchedulesState`
88
+ // refreshes the roster on a successful cancel, and a failure gets the same visible notice as Stop.
89
+ background_task_control_result: 'visible-notice',
90
+ protocol_error: 'visible-notice',
91
+ resume_gap: 'transport-control',
92
+ // #3282 §4a: the Settings screen's own correlated request/response state (`useSettingsState`).
93
+ settings: 'reducer-state',
94
+ settings_error: 'reducer-state',
95
+ } as const satisfies Readonly<Record<TServerMessage['type'], TServerMessageHandling>>;
@@ -0,0 +1,344 @@
1
+ import type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
2
+ import type { TConnectionStatus, TClientMessage } from '../client/ws-session-client.js';
3
+ import type { TPendingPrompt } from './prompt-state.js';
4
+ import type { TActionResponse } from '@robota-sdk/agent-interface-transport';
5
+ import type {
6
+ IDiffLine,
7
+ IToolAppViewRef,
8
+ IToolState,
9
+ ISettingsSnapshot,
10
+ TDriverId,
11
+ TPermissionResultValue,
12
+ TSettingsPatch,
13
+ } from '@robota-sdk/agent-interface-session';
14
+ import type {
15
+ IBackgroundTaskState,
16
+ IExecutionDetailRecord,
17
+ IExecutionWorkspaceSnapshot,
18
+ } from '@robota-sdk/agent-interface-execution';
19
+ import type { IWireAgentDefinitionSummary, TServerMessage } from '@robota-sdk/agent-transport';
20
+
21
+ export type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
22
+
23
+ export type TPersonalUsageReport = Extract<
24
+ TServerMessage,
25
+ { type: 'personal_usage_report' }
26
+ >['report'];
27
+ export type TStoredSessionUsageReport = Extract<
28
+ TServerMessage,
29
+ { type: 'stored_session_usage_report' }
30
+ >['report'];
31
+ /** The commands and skills the session offers — what a `/` menu lists. */
32
+ export type TCommandCatalog = Omit<Extract<TServerMessage, { type: 'commands' }>, 'type'>;
33
+ /** The session's status beside the conversation: model, permission mode, effort, context. */
34
+ export type TSessionStatus = Extract<TServerMessage, { type: 'session_status' }>['status'];
35
+
36
+ /**
37
+ * #3282 §2 (part 2): the models a person may switch to, grouped by configured provider profile —
38
+ * what the model control's pop-up menu is built from. `null` until requested (`list-models`).
39
+ */
40
+ export type TModelListSnapshot = Omit<
41
+ Extract<TServerMessage, { type: 'model_list' }>,
42
+ 'type' | 'requestId'
43
+ >;
44
+
45
+ /** The host's sessions in this workspace, which one is current, and the records it could not read. */
46
+ export type TSessionListing = Extract<TServerMessage, { type: 'sessions' }>['listing'];
47
+ /** Why the host did not list its sessions: it cannot (`not_available`), or listing failed. */
48
+ export type TSessionsError = Omit<
49
+ Extract<TServerMessage, { type: 'sessions_error' }>,
50
+ 'type' | 'requestId'
51
+ >;
52
+
53
+ export type TCurrentSessionUsageReport = Extract<
54
+ TServerMessage,
55
+ { type: 'usage_report' }
56
+ >['report'];
57
+
58
+ /** #3282 §4c — the Project panel's reads. Each result crosses the wire unchanged from the session
59
+ * method's own return value (see `wire-messages.ts`'s doc comment on `project_status`). */
60
+ export type TProjectStatusRead = Extract<TServerMessage, { type: 'project_status' }>['result'];
61
+ export type TProjectDiffRead = Extract<TServerMessage, { type: 'project_diff' }>['result'];
62
+ export type TProjectMemoryRead = Extract<TServerMessage, { type: 'project_memory' }>['result'];
63
+
64
+ export interface IConversationMessage {
65
+ id: string;
66
+ role: 'user' | 'assistant';
67
+ content: string;
68
+ isStreaming?: boolean;
69
+ /** REMOTE-014 E5: the co-driving author of a user turn. */
70
+ author?: string;
71
+ }
72
+
73
+ /**
74
+ * The outcome of a slash command, shown in the conversation where it was typed — the TUI adds the
75
+ * same outcome to its transcript. `info` is a command this surface cannot carry out.
76
+ */
77
+ export interface ICommandOutputEntry {
78
+ id: string;
79
+ role: 'command';
80
+ name: string;
81
+ content: string;
82
+ tone: 'success' | 'error' | 'info';
83
+ }
84
+
85
+ /** The tool calls of one finished turn, kept in the conversation ahead of the reply they led to. */
86
+ export interface IToolGroupEntry {
87
+ id: string;
88
+ role: 'tools';
89
+ tools: readonly IActiveTool[];
90
+ }
91
+
92
+ /** One file an Edit/Write call touched during a turn, and the (latest) diff a click can open. */
93
+ export interface IChangedFileSummary {
94
+ path: string;
95
+ added: number;
96
+ removed: number;
97
+ diffLines: readonly IDiffLine[];
98
+ }
99
+
100
+ /** #3288: a turn that changed files ends with this compact summary row. */
101
+ export interface IChangedFilesEntry {
102
+ id: string;
103
+ role: 'changed-files';
104
+ files: readonly IChangedFileSummary[];
105
+ }
106
+
107
+ export type TConversationEntry =
108
+ IConversationMessage | ICommandOutputEntry | IToolGroupEntry | IChangedFilesEntry;
109
+
110
+ export interface IActiveTool {
111
+ id: string;
112
+ name: string;
113
+ status: 'running' | 'done' | 'error';
114
+ input?: string;
115
+ result?: IToolState['result'];
116
+ /** #3288: correlates this call across `tool_start`/`tool_end` — how a finished call is matched. */
117
+ executionId?: string;
118
+ /** #3288: a unified diff (Edit/Write), built server-side — the same one an approval prompt showed. */
119
+ diffLines?: readonly IDiffLine[];
120
+ /** #3288: the file `diffLines` concerns, workspace-relative when the server could make it so. */
121
+ diffFile?: string;
122
+ /** #3288: the tool's raw result payload (Shell output, Read content, …), already capped server-side. */
123
+ toolResultData?: string;
124
+ /** Admitted observations; opaque resource URIs do not authorize a client-side fetch. */
125
+ toolResultParts?: IToolState['toolResultParts'];
126
+ /** #3288: a workspace-relative display form of a path argument, additive to `input`/`firstArg`. */
127
+ displayPath?: string;
128
+ /** #3288: set when this call is a projected `/command` tool — render "Ran /<commandName>". */
129
+ commandName?: string;
130
+ /** #3288: true for an internal signal tool (e.g. goal-status) — never rendered as a call. */
131
+ internal?: boolean;
132
+
133
+ appView?: IToolAppViewRef;
134
+ }
135
+
136
+ /**
137
+ * #3280 §2: a prompt the host queued behind the running turn — the server has already taken it, so it
138
+ * is not lost, but it will not run until the turn ends. `count` is the TOTAL number waiting (the shown
139
+ * `text` plus any behind it), matching the wire's `pendingCount`.
140
+ */
141
+ export interface IQueuedPrompt {
142
+ text: string;
143
+ count: number;
144
+ }
145
+
146
+ export interface ISessionNotice {
147
+ id: string;
148
+ /**
149
+ * `session-change-refused`: the host refused a new or switch this surface asked for.
150
+ * `background-task-control-failed`: a Stop in the Agents panel did not take effect.
151
+ * `command-failed` (#3282 §2 part 2): a change made through a status control (model/mode/effort)
152
+ * failed — a plain notice rather than a conversation card, since the control's own label is where
153
+ * a successful change confirms itself and the label stays unchanged when it fails.
154
+ */
155
+ kind:
156
+ | 'session-error'
157
+ | 'protocol-error'
158
+ | 'session-change-refused'
159
+ | 'background-task-control-failed'
160
+ | 'command-failed'
161
+ | 'context-compacted';
162
+ message: string;
163
+ /**
164
+ * A `session-error` classified at the wire boundary (#3289 §3) — present only when the host could
165
+ * tell what kind of failure this was. `SessionNotices` maps it to one plain sentence and keeps
166
+ * `message` behind a "Details" disclosure; absent, it shows `message` as before.
167
+ */
168
+ code?: 'auth' | 'rate_limit' | 'model_unavailable' | 'network' | 'provider';
169
+ provider?: string;
170
+ retryAfterSeconds?: number;
171
+ /**
172
+ * For `code: 'model_unavailable'`, the model the failed request tried — captured when the notice
173
+ * was created (from the wire frame's own `model`, or `sessionStatus` at that moment), never read
174
+ * live off the CURRENT session status. The person may switch models before dismissing the notice,
175
+ * and a live read would then blame the new model for the old failure.
176
+ */
177
+ model?: string;
178
+ }
179
+
180
+ export interface ISessionClientHandle {
181
+ connect: () => void;
182
+ disconnect: () => void;
183
+ send: (msg: TClientMessage) => void;
184
+ }
185
+
186
+ export type TMakeSessionClient<TStatus extends string = TConnectionStatus> = (callbacks: {
187
+ onMessage: (msg: TServerMessage) => void;
188
+ onStatusChange: (status: TStatus) => void;
189
+ }) => ISessionClientHandle;
190
+
191
+ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
192
+ status: TStatus;
193
+ /**
194
+ * Set by `useWsSession` once its reconnect retries give up (issue #3280 §5): the runtime is not
195
+ * coming back by itself. Cleared again once `status` reaches `connected`. Undefined for a reducer
196
+ * with no such concept (e.g. the WebRTC surface, which has its own `failed`/`refused` statuses).
197
+ */
198
+ connectionLost?: boolean;
199
+ messages: TConversationEntry[];
200
+ activeTools: IActiveTool[];
201
+ streamingText: string;
202
+ isThinking: boolean;
203
+ executionWorkspace: IExecutionWorkspaceSnapshot | null;
204
+ sessionName: string | null;
205
+ /** This connection's own driver id, learned from the server's first frame; null until then. */
206
+ ownDriverId: TDriverId | null;
207
+ /** Null until the session has answered; refreshed after every command. */
208
+ commandCatalog: TCommandCatalog | null;
209
+ /** Null until the session has answered; refreshed after every command and turn. */
210
+ sessionStatus: TSessionStatus | null;
211
+ /**
212
+ * #3282 §2 (part 2): the models the model control's pop-up menu offers. Null until requested — the
213
+ * menu asks for it when it opens (or refreshes it) rather than every surface fetching it eagerly.
214
+ */
215
+ modelList: TModelListSnapshot | null;
216
+ /** Ask the session for the current model list (a fresh `list-models` round trip). */
217
+ requestModelList: () => void;
218
+ /** Null until the host has answered; refreshed on connect, after a switch, a rename and a turn. */
219
+ sessionListing: TSessionListing | null;
220
+ /** Set when the host answered the latest listing request with an error instead. */
221
+ sessionsError: TSessionsError | null;
222
+ requestSessions: () => void;
223
+ /** Make another stored session current. A refusal arrives as a notice saying why. */
224
+ switchSession: (sessionId: string) => void;
225
+ /** Start a fresh session and make it current. */
226
+ newSession: () => void;
227
+ /** Rename a row in the list — current or not. A refusal arrives as a notice saying why. */
228
+ renameSessionInList: (sessionId: string, name: string) => void;
229
+ /** Delete a stored session for good. A refusal arrives as a notice saying why. */
230
+ deleteSession: (sessionId: string) => void;
231
+ /** Whether the session sidebar is shown; `/resume` opens it. */
232
+ sessionSidebarOpen: boolean;
233
+ setSessionSidebarOpen: (open: boolean) => void;
234
+ send: (msg: TClientMessage) => void;
235
+ /**
236
+ * #3282 §2 (part 2): run a command the SAME way `send({type:'command', ...})` does, but mark its
237
+ * outcome as controls-triggered — `useSessionClient` skips the conversation card for a change made
238
+ * this way (the control's own label confirms it instead), while a typed command keeps its card.
239
+ */
240
+ sendCommandSilently: (name: string, args?: string) => void;
241
+ pendingPrompts: readonly TPendingPrompt[];
242
+ /** #3280 §2: the prompt queued behind a running turn, or null when none is queued. */
243
+ queuedPrompt: IQueuedPrompt | null;
244
+ answerPermission: (id: string, result: TPermissionResultValue) => void;
245
+ answerAsk: (id: string, response: TActionResponse) => void;
246
+ personalUsageStatus: 'idle' | 'loading' | 'ready' | 'error';
247
+ personalUsageReport: TPersonalUsageReport | null;
248
+ personalUsageError: string | null;
249
+ requestPersonalUsage: (period: '7d' | '30d') => void;
250
+ storedSessionUsageStatus: 'idle' | 'loading' | 'ready' | 'error';
251
+ storedSessionUsageReport: TStoredSessionUsageReport | null;
252
+ storedSessionUsageSessionId: string | null;
253
+ storedSessionUsageError: string | null;
254
+ requestStoredSessionUsage: (sessionId: string) => void;
255
+ currentSessionUsageStatus: 'idle' | 'loading' | 'ready' | 'error';
256
+ currentSessionUsageReport: TCurrentSessionUsageReport | null;
257
+ requestCurrentSessionUsage: () => void;
258
+ sessionNotices: readonly ISessionNotice[];
259
+ dismissSessionNotice: (id: string) => void;
260
+ /** #3282 §4a: whether the Settings modal is shown. `/settings` and the gear button both open it. */
261
+ settingsOpen: boolean;
262
+ settingsStatus: 'idle' | 'loading' | 'ready' | 'error';
263
+ /** Null until the first `get-settings` reply arrives. */
264
+ settingsSnapshot: ISettingsSnapshot | null;
265
+ /** The plain message from the most recent failed read or write, if any. */
266
+ settingsError: string | null;
267
+ /** #3282 §4 part b-2: which section the screen should land on for the open in progress. */
268
+ settingsInitialSectionId: string | null;
269
+ /**
270
+ * Opens the screen and (re-)fetches its snapshot — the "reopen and see the new value" path.
271
+ * `sectionId` jumps straight to that section (e.g. `/plugin` → `'plugins'`, "Manage providers…"
272
+ * (#3282 §2/§4b) → `'providers'`); omitted opens on the screen's own default (General).
273
+ */
274
+ openSettings: (sectionId?: string) => void;
275
+ closeSettings: () => void;
276
+ /** A failed write leaves `settingsSnapshot` at its previous value; `settingsError` names why. */
277
+ updateSettings: (patch: TSettingsPatch) => void;
278
+ /** #3282 §4e: whether the Help sheet is shown. `/help` opens it instead of reaching the session. */
279
+ helpOpen: boolean;
280
+ openHelp: () => void;
281
+ closeHelp: () => void;
282
+ /** #3288 §1: the id of the execution-workspace entry open in the detail sheet, or null when closed. */
283
+ openEntryId: string | null;
284
+ executionDetailStatus: 'idle' | 'loading' | 'ready' | 'error';
285
+ executionDetailRecords: readonly IExecutionDetailRecord[];
286
+ executionDetailError: string | null;
287
+ /** True once the last page read had no further cursor — nothing more to load. */
288
+ executionDetailComplete: boolean;
289
+ openExecutionDetail: (entryId: string) => void;
290
+ loadMoreExecutionDetail: () => void;
291
+ closeExecutionDetail: () => void;
292
+ /** #3282 §4c — the Project panel's "Changes" section (git status). */
293
+ projectStatusState: 'idle' | 'loading' | 'ready' | 'error';
294
+ projectStatus: TProjectStatusRead | null;
295
+ requestProjectStatus: () => void;
296
+ /** #3282 §4c — the Project panel's "File diff" section, for the file `projectDiffPath` names. */
297
+ projectDiffState: 'idle' | 'loading' | 'ready' | 'error';
298
+ projectDiffPath: string | null;
299
+ projectDiff: TProjectDiffRead | null;
300
+ requestProjectDiff: (path: string) => void;
301
+ /** #3282 §4c — the Project panel's "Memory" section. */
302
+ projectMemoryState: 'idle' | 'loading' | 'ready' | 'error';
303
+ projectMemory: TProjectMemoryRead | null;
304
+ requestProjectMemory: () => void;
305
+ /**
306
+ * #3282 §4 part b-3: whether the agent switcher sheet is shown. `/agent` (bare) and this surface's
307
+ * own control both open it.
308
+ */
309
+ agentSwitcherOpen: boolean;
310
+ agentSwitcherStatus: 'idle' | 'loading' | 'ready' | 'error';
311
+ /** The available agents: name, one-line description, and where each is defined in plain words. */
312
+ agentDefinitions: readonly IWireAgentDefinitionSummary[];
313
+ /** The agent type `/agent <name>` (no prompt) currently selects — the checked row. */
314
+ currentAgentType: string | null;
315
+ /** The plain confirmation of the last selection's result, shown in the sheet — never a conversation card. */
316
+ agentSwitchMessage: string | null;
317
+ /** Opens the sheet and (re-)fetches its roster. */
318
+ openAgentSwitcher: () => void;
319
+ closeAgentSwitcher: () => void;
320
+ /** Runs the same path as `/agent <name>`. */
321
+ selectAgent: (agentType: string) => void;
322
+ /**
323
+ * #3282 §4 part b-3: the schedules shown in the Agents panel's "Scheduled" group — every
324
+ * `IBackgroundTaskState` of `kind: 'scheduled'`, refreshed on connect and after a write.
325
+ */
326
+ scheduledTasks: readonly IBackgroundTaskState<'scheduled'>[];
327
+ /** Runs the same path as `/schedule pause <id>` / `/schedule resume <id>`. */
328
+ pauseSchedule: (taskId: string) => void;
329
+ resumeSchedule: (taskId: string) => void;
330
+ /**
331
+ * Permanently stops a schedule — the same `cancel-background-task` write Stop already uses. A
332
+ * failure surfaces the same way a failed Stop does (#3288 §1's `background-task-control-failed`
333
+ * session notice) — no separate error state here.
334
+ */
335
+ deleteSchedule: (taskId: string) => void;
336
+
337
+ readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
338
+ callAppTool: (
339
+ viewKey: string,
340
+ name: string,
341
+ args: TAppToolArguments,
342
+ signal?: AbortSignal,
343
+ ) => Promise<TAppToolOutcome>;
344
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * CMD-004 Stage D — how the GUI surface answers a `ui_intent`.
3
+ *
4
+ * The server requester-routes `ui_intent`, so everything that arrives here was asked for by THIS
5
+ * surface and must be answered visibly. An intent with a GUI screen opens it (the session picker is
6
+ * the session sidebar, #3189; settings and the plugin manager both open the Settings modal, #3282
7
+ * §4a/§4 part b-2; the agent switcher is a sheet, #3282 §4 part b-3). #3282 §4e: an intent whose
8
+ * command is on the GUI's exclusion list never reaches here at all (`excluded-commands.ts` catches it
9
+ * before the session is asked) — this module only answers a screen this HOST cannot back even though
10
+ * the GUI knows how to render it (the session picker, when the host cannot list sessions), or a kind
11
+ * this build does not know at all — and it never says "not available on this surface": each gets its
12
+ * own plain, honest sentence instead.
13
+ */
14
+
15
+ import { excludedCommandMessage } from './excluded-commands.js';
16
+
17
+ import type { TCommandUiIntent } from '@robota-sdk/agent-interface-command';
18
+
19
+ /** The command a user runs for the screen an intent asks for — how the info line is labelled. */
20
+ export function uiIntentCommandName(intent: TCommandUiIntent): string {
21
+ switch (intent.type) {
22
+ case 'show-plugin-manager':
23
+ return 'plugin';
24
+ case 'show-settings':
25
+ return 'settings';
26
+ case 'show-session-picker':
27
+ return 'resume';
28
+ case 'show-agent-switcher':
29
+ return 'agent';
30
+ case 'show-theme-picker':
31
+ return 'theme';
32
+ default:
33
+ return (intent as { type: string }).type;
34
+ }
35
+ }
36
+
37
+ /** The GUI screen an intent opens, or null when this surface has none for it. */
38
+ export function guiScreenForUiIntent(
39
+ intent: TCommandUiIntent,
40
+ ): 'session-sidebar' | 'settings' | 'settings-plugins' | 'agent-switcher' | null {
41
+ if (intent.type === 'show-session-picker') return 'session-sidebar';
42
+ if (intent.type === 'show-settings') return 'settings';
43
+ // #3282 §4 part b-2: `/plugin` opens the Settings screen's Plugins section, the way #3331 mapped
44
+ // `show-settings` — replacing the "not available on this surface" line this intent used to get.
45
+ if (intent.type === 'show-plugin-manager') return 'settings-plugins';
46
+ if (intent.type === 'show-agent-switcher') return 'agent-switcher';
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Describe one intent for this surface: a plain, honest sentence for a screen this HOST cannot back
52
+ * right now (a session picker with no session list), the `/theme` exclusion this intent defensively
53
+ * reuses, or an unrecognized future kind. Never called for `show-settings`, `show-plugin-manager` or
54
+ * `show-agent-switcher` — all three always have a screen now (#3282 §4a, §4 part b-2, §4 part b-3) —
55
+ * but their cases are left out of this switch rather than kept as dead branches that would claim
56
+ * otherwise; an unknown wire-level kind (a newer host) still yields an explicit notice naming the raw
57
+ * kind, so the "never a silent no-op" floor holds for future intents too.
58
+ */
59
+ export function describeUiIntentForGui(intent: TCommandUiIntent): string {
60
+ switch (intent.type) {
61
+ case 'show-session-picker':
62
+ // The GUI knows how to render this screen (`guiScreenForUiIntent` says so); it is only reached
63
+ // here when the HOST itself cannot list sessions, which is this host's limitation, not a
64
+ // missing GUI feature.
65
+ return 'This host cannot list sessions, so there is nothing to resume.';
66
+ case 'show-theme-picker':
67
+ // Defensive only: `/theme` is on the exclusion list and never reaches the session from the GUI.
68
+ return excludedCommandMessage('theme');
69
+ default:
70
+ // Spec-mandated unsupported signal for an intent kind this build does not know (not a
71
+ // fallback: the explicit notice IS the defined behavior for an unrenderable intent).
72
+ return `This client does not have a screen for '${(intent as { type: string }).type}' yet.`;
73
+ }
74
+ }
@@ -0,0 +1,125 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ import type { IWsSessionState } from './session-client-types.js';
4
+ import type { TClientMessage } from '../client/ws-session-client.js';
5
+ import type { IWireAgentDefinitionSummary } from '@robota-sdk/agent-transport';
6
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
7
+
8
+ type TAgentSwitcherState = Pick<
9
+ IWsSessionState,
10
+ | 'agentSwitcherOpen'
11
+ | 'agentSwitcherStatus'
12
+ | 'agentDefinitions'
13
+ | 'currentAgentType'
14
+ | 'agentSwitchMessage'
15
+ | 'openAgentSwitcher'
16
+ | 'closeAgentSwitcher'
17
+ | 'selectAgent'
18
+ >;
19
+
20
+ let requestCounter = 0;
21
+ function nextRequestId(prefix: string): string {
22
+ requestCounter += 1;
23
+ return `${prefix}_${requestCounter}_${Date.now()}`;
24
+ }
25
+
26
+ /**
27
+ * #3282 §4 part b-3: the agent switcher sheet's own correlated request/response state, the same
28
+ * shape `useSettingsState` already uses for `get-settings`/`update-settings` — independent from the
29
+ * chat conversation, because choosing an agent is answered by the sheet's own confirmation line
30
+ * ("Default agent: Explore"), never a conversation card (issue #3282 §4's decided design).
31
+ *
32
+ * `openAgentSwitcher` re-fetches every time (`/agent` bare, or reopening after a close), so a change
33
+ * made elsewhere reaches an open-then-reopened sheet, matching `useSettingsState`'s own reasoning.
34
+ *
35
+ * Choosing an agent runs the SAME path as typing `/agent <name>` — a plain `command` wire message —
36
+ * so the write goes through `executeCommand` exactly like every other command; this hook only
37
+ * correlates its own request so the reply lands in the sheet, not the conversation. The central
38
+ * `command_result` case in `useSessionClient` calls `resolveSwitchResult` to hand it the reply
39
+ * (and, critically, still performs the shared `commandsInFlightRef` bookkeeping itself — this hook
40
+ * never short-circuits `command_result` the way `handleAgentDefinitionsMessage` short-circuits reads).
41
+ */
42
+ export function useAgentSwitcherState(send: (msg: TClientMessage) => void): TAgentSwitcherState & {
43
+ /** Handles `agent_definitions` only — never `command_result` (see the note above). */
44
+ handleAgentDefinitionsMessage: (msg: TServerMessage) => boolean;
45
+ /**
46
+ * Called from the central `command_result` case. Returns true when this requestId was the
47
+ * sheet's own pending switch, meaning the central handler must not also add a conversation card.
48
+ */
49
+ resolveSwitchResult: (
50
+ requestId: string | undefined,
51
+ result: { success: boolean; message: string },
52
+ ) => boolean;
53
+ } {
54
+ const [agentSwitcherOpen, setAgentSwitcherOpen] = useState(false);
55
+ const [agentSwitcherStatus, setAgentSwitcherStatus] =
56
+ useState<IWsSessionState['agentSwitcherStatus']>('idle');
57
+ const [agentDefinitions, setAgentDefinitions] = useState<
58
+ readonly IWireAgentDefinitionSummary[]
59
+ >([]);
60
+ const [currentAgentType, setCurrentAgentType] = useState<string | null>(null);
61
+ const [agentSwitchMessage, setAgentSwitchMessage] = useState<string | null>(null);
62
+ const listRequestRef = useRef<string | null>(null);
63
+ const switchRequestRef = useRef<string | null>(null);
64
+
65
+ const handleAgentDefinitionsMessage = useCallback((msg: TServerMessage): boolean => {
66
+ if (msg.type !== 'agent_definitions') return false;
67
+ if (listRequestRef.current !== msg.requestId) return true; // superseded by a later request
68
+ setAgentDefinitions(msg.agents);
69
+ setCurrentAgentType(msg.current);
70
+ setAgentSwitcherStatus('ready');
71
+ return true;
72
+ }, []);
73
+
74
+ const requestAgentDefinitions = useCallback((): void => {
75
+ const requestId = nextRequestId('get_agent_definitions');
76
+ listRequestRef.current = requestId;
77
+ send({ type: 'get-agent-definitions', requestId });
78
+ }, [send]);
79
+
80
+ const openAgentSwitcher = useCallback((): void => {
81
+ setAgentSwitcherOpen(true);
82
+ setAgentSwitcherStatus((current) => (current === 'ready' ? 'ready' : 'loading'));
83
+ setAgentSwitchMessage(null);
84
+ requestAgentDefinitions();
85
+ }, [requestAgentDefinitions]);
86
+
87
+ const closeAgentSwitcher = useCallback((): void => {
88
+ setAgentSwitcherOpen(false);
89
+ }, []);
90
+
91
+ const selectAgent = useCallback(
92
+ (agentType: string): void => {
93
+ const requestId = nextRequestId('agent_switch');
94
+ switchRequestRef.current = requestId;
95
+ setAgentSwitchMessage(null);
96
+ // The SAME path `/agent <name>` runs: a plain command, not a dedicated write message.
97
+ send({ type: 'command', name: 'agent', args: agentType, requestId });
98
+ },
99
+ [send],
100
+ );
101
+
102
+ const resolveSwitchResult = useCallback(
103
+ (requestId: string | undefined, result: { success: boolean; message: string }): boolean => {
104
+ if (requestId === undefined || switchRequestRef.current !== requestId) return false;
105
+ switchRequestRef.current = null;
106
+ setAgentSwitchMessage(result.message);
107
+ if (result.success) requestAgentDefinitions();
108
+ return true;
109
+ },
110
+ [requestAgentDefinitions],
111
+ );
112
+
113
+ return {
114
+ agentSwitcherOpen,
115
+ agentSwitcherStatus,
116
+ agentDefinitions,
117
+ currentAgentType,
118
+ agentSwitchMessage,
119
+ openAgentSwitcher,
120
+ closeAgentSwitcher,
121
+ selectAgent,
122
+ handleAgentDefinitionsMessage,
123
+ resolveSwitchResult,
124
+ };
125
+ }