@robota-sdk/agent-ui-web 3.2.1 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/index.cjs +161 -118
  3. package/dist/index.d.cts +65 -63
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +65 -63
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +161 -118
  8. package/dist/index.js.map +1 -1
  9. package/docs/SPEC.md +6 -1
  10. package/package.json +6 -6
  11. package/src/client/ws-session-client.ts +1 -1
  12. package/src/clipboard.ts +2 -2
  13. package/src/components/AgentActivityPanel.tsx +46 -39
  14. package/src/components/AgentSwitcherSheet.tsx +2 -2
  15. package/src/components/Composer.tsx +40 -37
  16. package/src/components/ConversationView.tsx +14 -14
  17. package/src/components/Dialog.tsx +6 -6
  18. package/src/components/DiffLines.tsx +2 -2
  19. package/src/components/ExecutionDetailSheet.tsx +1 -1
  20. package/src/components/HelpSheet.tsx +2 -2
  21. package/src/components/PermissionPrompt.tsx +22 -22
  22. package/src/components/PopupMenu.tsx +1 -1
  23. package/src/components/ProjectPanel.tsx +2 -2
  24. package/src/components/SessionMonitor.tsx +1 -1
  25. package/src/components/SessionSidebar.tsx +8 -8
  26. package/src/components/SessionSurface.tsx +83 -56
  27. package/src/components/SessionSurfaceChrome.tsx +8 -8
  28. package/src/components/SettingsGeneralSection.tsx +1 -1
  29. package/src/components/SettingsMcpSection.tsx +1 -1
  30. package/src/components/SettingsPermissionsSection.tsx +1 -1
  31. package/src/components/SettingsPluginsSection.tsx +1 -1
  32. package/src/components/SettingsProvidersSection.tsx +3 -3
  33. package/src/components/SettingsScreen.tsx +3 -3
  34. package/src/components/StatusControls.tsx +12 -12
  35. package/src/components/composer-attachments.ts +5 -5
  36. package/src/driver-labels.ts +3 -3
  37. package/src/execution-entry-status.ts +1 -1
  38. package/src/goal-view.ts +1 -1
  39. package/src/hooks/command-menu.ts +2 -2
  40. package/src/hooks/excluded-commands.ts +2 -2
  41. package/src/hooks/prompt-state.ts +4 -4
  42. package/src/hooks/server-message-handling.ts +13 -11
  43. package/src/hooks/session-client-types.ts +36 -30
  44. package/src/hooks/ui-intent-state.ts +6 -6
  45. package/src/hooks/use-agent-switcher-state.ts +2 -2
  46. package/src/hooks/use-app-view-requests.ts +8 -1
  47. package/src/hooks/use-media-query.ts +1 -1
  48. package/src/hooks/use-model-list.ts +1 -1
  49. package/src/hooks/use-project-panel.ts +1 -1
  50. package/src/hooks/use-schedules-state.ts +4 -4
  51. package/src/hooks/use-session-directory.ts +11 -6
  52. package/src/hooks/use-settings-state.ts +4 -4
  53. package/src/hooks/useSessionClient.ts +81 -50
  54. package/src/index.ts +5 -5
  55. package/src/schedule-view.ts +1 -1
  56. package/src/styles/surface.css +1 -1
package/docs/SPEC.md CHANGED
@@ -68,6 +68,11 @@ does not own session lifecycle, conversation history, or agent runtime state.
68
68
  a schedule or a goal alone (no running background task or loop) still opens the rail, and a
69
69
  schedule's Pause/Resume and Delete, and a goal's Cancel, run the same command/control paths their
70
70
  slash commands already use — the panel adds no write this package did not already expose.
71
+ - **The surface follows the host's role.** The session client learns its effective role from the
72
+ host when it connects and, while it observes, sends nothing an observer may not send; the surface
73
+ then offers only what an observer can do: the conversation, its work and the project's reads, with
74
+ no composer, answers, session switching or control that changes the session or its work. The host
75
+ remains the authority; hiding a control is presentation, not enforcement.
71
76
  - The title bar and the page title both name the workspace folder the session works in, once the
72
77
  host has said one; the desktop window follows the page title.
73
78
  - A pending permission or ask prompt is visible whatever view is open, because a gated turn waits
@@ -90,7 +95,7 @@ does not own session lifecycle, conversation history, or agent runtime state.
90
95
  representation, when it has one, is only its disposable wake timer — cancelling that would leave
91
96
  the loop itself running. Every other entry sends `cancel-background-task`.
92
97
  - The Project panel (git status, one file's diff, project memory) correlates its own requests the
93
- same way, and every file status it shows is a plain word, never a raw git XY code (#3277 "usable by
98
+ same way, and every file status it shows is a plain word, never a raw git XY code ("usable by
94
99
  anyone"); a workspace that is not a git repository, or a diff whose path the host refused, is one
95
100
  plain sentence, never left as a stuck loading state.
96
101
  - A session error finalizes any partial streamed text and clears thinking/running-tool state
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-ui-web",
3
- "version": "3.2.1",
3
+ "version": "3.3.1",
4
4
  "description": "Browser-safe React session presentation, wire reducer, WebSocket client and runtime-host contract for consumer-owned agent renderers.",
5
5
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
6
6
  "type": "module",
@@ -60,11 +60,11 @@
60
60
  "pretendard": "^1.3.9",
61
61
  "react-markdown": "^10.1.0",
62
62
  "remark-gfm": "^4.0.1",
63
- "@robota-sdk/agent-interface-execution": "3.2.1",
64
- "@robota-sdk/agent-interface-session": "3.2.1",
65
- "@robota-sdk/agent-transport": "3.2.1",
66
- "@robota-sdk/agent-interface-command": "3.2.1",
67
- "@robota-sdk/agent-interface-transport": "3.2.1"
63
+ "@robota-sdk/agent-interface-command": "^3.2.3",
64
+ "@robota-sdk/agent-interface-session": "^3.2.3",
65
+ "@robota-sdk/agent-transport": "^3.3.1",
66
+ "@robota-sdk/agent-interface-transport": "^3.2.3",
67
+ "@robota-sdk/agent-interface-execution": "^3.2.3"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "react": ">=18"
@@ -72,7 +72,7 @@ export function createWsSessionClient(
72
72
  ws.onmessage = (event: MessageEvent): void => {
73
73
  const data = event.data;
74
74
  if (typeof data !== 'string') return;
75
- // Issue #2045: `raw → owner decoder → typed`. A malformed frame (invalid JSON or an invalid
75
+ // `raw → owner decoder → typed`. A malformed frame (invalid JSON or an invalid
76
76
  // shape) must not throw inside the handler (it would break the socket's message pump and
77
77
  // freeze the UI) and must not be cast onward; surface it via the normal path.
78
78
  const decoded = decodeFrame(data, decodeServerMessage);
package/src/clipboard.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
 
3
3
  /**
4
- * Copies `text` to the clipboard for the "Copy code" / "Copy message" buttons (#3289 §2): the async
4
+ * Copies `text` to the clipboard for the "Copy code" / "Copy message" buttons: the async
5
5
  * Clipboard API first, and — for a page that denies it (no permission granted yet, a browser that
6
6
  * never implements it, or a context that disallows it outright) — a same-tick `execCommand('copy')`
7
7
  * fallback through a throwaway, invisible textarea. Rejects only once both have failed, so a caller
@@ -59,7 +59,7 @@ const FEEDBACK_MS = 2000;
59
59
  /**
60
60
  * The "Copy code" / "Copy message" buttons' shared feedback state: `copied` or `error` for a couple of
61
61
  * seconds, then back to `idle` — drives both the button's own label swap and an `aria-live` region a
62
- * screen reader announces without moving focus (#3289 §2).
62
+ * screen reader announces without moving focus.
63
63
  */
64
64
  export function useCopyFeedback(): { status: TCopyStatus; copy: (text: string) => void } {
65
65
  const [status, setStatus] = useState<TCopyStatus>('idle');
@@ -31,22 +31,22 @@ import type { IGoalState } from '@robota-sdk/agent-interface-session';
31
31
 
32
32
  interface IAgentActivityPanelProps {
33
33
  tasks: readonly IExecutionWorkspaceEntry[];
34
- /** #3282 §4 part b-3: the "Scheduled" group — every `kind: 'scheduled'` background task. */
34
+ /** The "Scheduled" group — every `kind: 'scheduled'` background task. */
35
35
  schedules?: readonly IBackgroundTaskState<'scheduled'>[];
36
- /** #3282 §4 part b-3: the current goal (`/goal`), or null/absent when none is set. */
36
+ /** The current goal (`/goal`), or null/absent when none is set. */
37
37
  goal?: IGoalState | null;
38
38
  onPauseSchedule?: (taskId: string) => void;
39
39
  onResumeSchedule?: (taskId: string) => void;
40
40
  onDeleteSchedule?: (taskId: string) => void;
41
41
  onCancelGoal?: () => void;
42
42
  className?: string;
43
- /** #3288 §1: open a non-main-thread entry's detail sheet. */
43
+ /** Open a non-main-thread entry's detail sheet. */
44
44
  onSelect?: (entry: IExecutionWorkspaceEntry) => void;
45
- /** #3288 §1: the main thread entry was clicked — the conversation beside this panel is it. */
45
+ /** The main thread entry was clicked — the conversation beside this panel is it. */
46
46
  onReturnToConversation?: () => void;
47
- /** #3288 §1: Stop was clicked on a running/queued entry. */
47
+ /** Stop was clicked on a running/queued entry. */
48
48
  onStop?: (entry: IExecutionWorkspaceEntry) => void;
49
- /** #3288 §1: the entry whose detail sheet is currently open, for a highlighted row. */
49
+ /** The entry whose detail sheet is currently open, for a highlighted row. */
50
50
  selectedEntryId?: string;
51
51
  }
52
52
 
@@ -115,7 +115,7 @@ export function AgentActivityPanel({
115
115
  task={task}
116
116
  onPause={onPauseSchedule}
117
117
  onResume={onResumeSchedule}
118
- onRequestDelete={() => setPendingDeleteId(task.id)}
118
+ onRequestDelete={onDeleteSchedule ? () => setPendingDeleteId(task.id) : undefined}
119
119
  />
120
120
  ))}
121
121
  </div>
@@ -221,7 +221,7 @@ function AgentCard({
221
221
  );
222
222
  }
223
223
 
224
- /** #3282 §4 part b-3: one row of the "Scheduled" group — what it runs, when next, status, controls. */
224
+ /** One row of the "Scheduled" group — what it runs, when next, status, controls. */
225
225
  function ScheduleCard({
226
226
  task,
227
227
  onPause,
@@ -231,7 +231,8 @@ function ScheduleCard({
231
231
  task: IBackgroundTaskState<'scheduled'>;
232
232
  onPause?: (taskId: string) => void;
233
233
  onResume?: (taskId: string) => void;
234
- onRequestDelete: () => void;
234
+ /** Absent when the schedule is shown without controls (an observer). */
235
+ onRequestDelete?: () => void;
235
236
  }): React.ReactElement {
236
237
  const isPaused = task.status === 'paused';
237
238
  return (
@@ -242,39 +243,45 @@ function ScheduleCard({
242
243
  <p className="mt-1 text-[13px] text-muted-foreground">
243
244
  {describeScheduleTiming(task)} · {describeScheduleStatus(task)}
244
245
  </p>
245
- <div className="mt-2 flex items-center gap-3">
246
- {isPaused ? (
247
- <button
248
- type="button"
249
- onClick={() => onResume?.(task.id)}
250
- className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
251
- >
252
- <Play size={11} fill="currentColor" aria-hidden="true" />
253
- Resume
254
- </button>
255
- ) : (
256
- <button
257
- type="button"
258
- onClick={() => onPause?.(task.id)}
259
- className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
260
- >
261
- <Pause size={11} fill="currentColor" aria-hidden="true" />
262
- Pause
263
- </button>
264
- )}
265
- <button
266
- type="button"
267
- onClick={onRequestDelete}
268
- className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-destructive hover:bg-destructive/10"
269
- >
270
- Delete…
271
- </button>
272
- </div>
246
+ {onResume || onPause || onRequestDelete ? (
247
+ <div className="mt-2 flex items-center gap-3">
248
+ {isPaused
249
+ ? onResume && (
250
+ <button
251
+ type="button"
252
+ onClick={() => onResume(task.id)}
253
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
254
+ >
255
+ <Play size={11} fill="currentColor" aria-hidden="true" />
256
+ Resume
257
+ </button>
258
+ )
259
+ : onPause && (
260
+ <button
261
+ type="button"
262
+ onClick={() => onPause(task.id)}
263
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
264
+ >
265
+ <Pause size={11} fill="currentColor" aria-hidden="true" />
266
+ Pause
267
+ </button>
268
+ )}
269
+ {onRequestDelete && (
270
+ <button
271
+ type="button"
272
+ onClick={onRequestDelete}
273
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-destructive hover:bg-destructive/10"
274
+ >
275
+ Delete…
276
+ </button>
277
+ )}
278
+ </div>
279
+ ) : null}
273
280
  </div>
274
281
  );
275
282
  }
276
283
 
277
- /** #3282 §4 part b-3: the current goal — objective, plain-word status, and Cancel. */
284
+ /** The current goal — objective, plain-word status, and Cancel. */
278
285
  function GoalCard({
279
286
  goal,
280
287
  onCancel,
@@ -293,7 +300,7 @@ function GoalCard({
293
300
  <p className="mt-1.5 pl-[26px] text-[13px] text-muted-foreground">
294
301
  {describeGoalStatus(goal)}
295
302
  </p>
296
- {goal.status === 'active' && (
303
+ {goal.status === 'active' && onCancel && (
297
304
  <div className="mt-2 pl-[26px]">
298
305
  <button
299
306
  type="button"
@@ -8,12 +8,12 @@ import { Dialog } from './Dialog.js';
8
8
  import type { IWsSessionState } from '../hooks/useSessionClient.js';
9
9
 
10
10
  /**
11
- * #3282 §4 part b-3 — the agent switcher: `show-agent-switcher` (`/agent` with no args) opens this
11
+ * The agent switcher: `show-agent-switcher` (`/agent` with no args) opens this
12
12
  * sheet instead of the surface's old "not available" line. It lists every agent this session can
13
13
  * run — name, one-line description, and where each is defined in plain words — with the current one
14
14
  * checked. Choosing a row runs the SAME path as typing `/agent <name>` (a plain `command`, wired in
15
15
  * `useAgentSwitcherState`); the reply is this sheet's own confirmation line, never a conversation
16
- * card (issue #3282 §4's decided design).
16
+ * card.
17
17
  */
18
18
  export function AgentSwitcherSheet({ state }: { state: IWsSessionState }): React.ReactElement | null {
19
19
  if (!state.agentSwitcherOpen) return null;
@@ -28,7 +28,7 @@ export interface IComposerHandle {
28
28
  focus: () => void;
29
29
  }
30
30
 
31
- /** The draft as persisted: the typed text plus any attachment chips (#3282 §4d). */
31
+ /** The draft as persisted: the typed text plus any attachment chips. */
32
32
  interface IStoredDraft {
33
33
  readonly text: string;
34
34
  readonly attachments: readonly IDraftAttachment[];
@@ -37,7 +37,7 @@ interface IStoredDraft {
37
37
  const EMPTY_DRAFT: IStoredDraft = { text: '', attachments: [] };
38
38
 
39
39
  /**
40
- * #3282 §4e: a command whose whole job is opening a GUI screen — choosing it from the `/` menu runs
40
+ * A command whose whole job is opening a GUI screen — choosing it from the `/` menu runs
41
41
  * it at once (its `ui_intent` opens the screen the normal way) instead of filling the draft and
42
42
  * waiting for Enter, since there is nothing useful to type after it. `help` never reaches the
43
43
  * session at all (`useSessionClient.ts`'s `send` opens the Help sheet locally). Every other command
@@ -55,14 +55,14 @@ function menuGroupLabel(item: ICommandMenuItem): 'Commands' | 'Skills' {
55
55
  }
56
56
 
57
57
  /**
58
- * #3280 §4: the unsent draft survives a Chat → Usage → Chat switch (the composer unmounts), a page
58
+ * The unsent draft survives a Chat → Usage → Chat switch (the composer unmounts), a page
59
59
  * reload, and a desktop relaunch — `localStorage`, not `sessionStorage`, since only `localStorage`
60
60
  * survives a closed window/tab being reopened. Namespaced (the configured draft prefix, matching
61
61
  * the configured restore key in `use-session-directory.ts`) so a page hosting other state under the
62
62
  * same origin does not collide. Per session id when one is known; a single fallback key before the
63
63
  * first status arrives (the gap is brief and is reconciled once it does — see the effect below).
64
64
  *
65
- * #3282 §4d: the stored value is now JSON (`IStoredDraft`), not the bare text string it used to be —
65
+ * The stored value is now JSON (`IStoredDraft`), not the bare text string it used to be —
66
66
  * `parseStoredDraft` treats anything that does not parse as that shape (including a draft saved
67
67
  * before this change shipped) as plain text with no attachments, so an old stored draft still loads.
68
68
  */
@@ -141,38 +141,38 @@ const ComposerInstance = forwardRef<
141
141
  catalog: TCommandCatalog | null;
142
142
  status: TSessionStatus | null;
143
143
  /**
144
- * False while the transport is not `connected` (issue #3280 §5): Enter and Send refuse to submit,
144
+ * False while the transport is not `connected`: Enter and Send refuse to submit,
145
145
  * and nothing typed is cleared or lost — the composer never sends into a socket that is not there.
146
- * #3282 §2 (part 2): the status chips and the `/` command menu are disabled the same way.
146
+ * The status chips and the `/` command menu are disabled the same way.
147
147
  */
148
148
  connected?: boolean;
149
- /** #3280 §2: a turn (or a blocking command) is running — Send becomes Stop and Esc stops it too. */
149
+ /** A turn (or a blocking command) is running — Send becomes Stop and Esc stops it too. */
150
150
  running: boolean;
151
151
  onStop: () => void;
152
- /** #3280 §2: the prompt queued behind the running turn, or null when none is queued. */
152
+ /** The prompt queued behind the running turn, or null when none is queued. */
153
153
  queued: IQueuedPrompt | null;
154
154
  onCancelQueue: () => void;
155
155
  /**
156
156
  * Opens the host's native multi-file dialog with real filesystem paths — present only on the
157
- * desktop app (#3282 §4d). Its absence means the attach button falls back to a plain HTML file
157
+ * desktop app. Its absence means the attach button falls back to a plain HTML file
158
158
  * picker, whose picks a browser can never resolve to a path (rule 3: shown plainly, nothing
159
159
  * attached).
160
160
  */
161
161
  pickFiles?: () => Promise<readonly IPickedFile[]>;
162
162
  /**
163
163
  * Resolves a dropped or picked `File` to its real filesystem path — present only on the desktop
164
- * app, via Electron's `webUtils.getPathForFile` (#3282 §4d). Its absence means a drop can never
164
+ * app, via Electron's `webUtils.getPathForFile`. Its absence means a drop can never
165
165
  * become an `@`-reference either.
166
166
  */
167
167
  getPathForFile?: (file: File) => string;
168
- /** #3282 §2 (part 2): the model control's pop-up menu — null until requested. */
168
+ /** The model control's pop-up menu — null until requested. */
169
169
  modelList?: TModelListSnapshot | null;
170
170
  onRequestModelList?: () => void;
171
171
  /** Applies a model/mode/effort choice without a conversation card (the control's label confirms it). */
172
172
  onSilentCommand?: (name: string, args?: string) => void;
173
173
  /**
174
- * #3282 §4b: "Manage providers…" opens Settings at the Providers & Models section. Defaults to
175
- * dispatching the bare `/provider` command (the pre-#3282-§4b behavior) for a caller that has not
174
+ * "Manage providers…" opens Settings at the Providers & Models section. Defaults to
175
+ * dispatching the bare `/provider` command (the legacy behavior) for a caller that has not
176
176
  * wired Settings — every shipped caller passes its own `state.openSettings('providers')`.
177
177
  */
178
178
  onManageProviders?: () => void;
@@ -238,25 +238,25 @@ const ComposerInstance = forwardRef<
238
238
  const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
239
239
  const [selected, setSelected] = useState(0);
240
240
  const [dismissed, setDismissed] = useState(false);
241
- // #3282 §2 (part 2): the slash menu is disabled while disconnected — none of its commands could run.
241
+ // The slash menu is disabled while disconnected — none of its commands could run.
242
242
  const menu = dismissed || !connected ? null : commandMenuFor(catalog, draft);
243
243
  useEffect(() => {
244
244
  setSelected(0);
245
245
  setDismissed(false);
246
246
  }, [draft]);
247
- // #3282 §4e: the menu can hold more rows than fit in its scroll area — keyboard navigation (below)
247
+ // The menu can hold more rows than fit in its scroll area — keyboard navigation (below)
248
248
  // must keep the highlighted row in view, not just move the highlight off-screen.
249
249
  const menuOptionRefs = useRef<(HTMLButtonElement | null)[]>([]);
250
250
  useEffect(() => {
251
251
  menuOptionRefs.current[selected]?.scrollIntoView?.({ block: 'nearest' });
252
252
  }, [selected, menu]);
253
- /** #3280 §4: every draft change is persisted at once, so a reload or relaunch loses nothing. */
253
+ /** Every draft change is persisted at once, so a reload or relaunch loses nothing. */
254
254
  const setDraft = (value: string): void => {
255
255
  stateRef.current = { ...stateRef.current, text: value };
256
256
  setDraftState(value);
257
257
  if (draftMode === 'persistent') writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
258
258
  };
259
- /** #3282 §4d: attachment chips persist alongside the text, under the same per-session draft key. */
259
+ /** Attachment chips persist alongside the text, under the same per-session draft key. */
260
260
  const setAttachments = (
261
261
  updater: readonly IDraftAttachment[] | ((current: readonly IDraftAttachment[]) => readonly IDraftAttachment[]),
262
262
  ): void => {
@@ -265,7 +265,7 @@ const ComposerInstance = forwardRef<
265
265
  setAttachmentsState(next);
266
266
  if (draftMode === 'persistent') writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
267
267
  };
268
- // #3280 §4: a session switch shows THAT session's own saved draft, never what was typed for
268
+ // A session switch shows THAT session's own saved draft, never what was typed for
269
269
  // another one. The session id becoming known for the very FIRST time (the fallback key was in use
270
270
  // until now) instead carries over what is already typed, rather than discarding it. A transient
271
271
  // `undefined` mid-switch, once a real id has already been seen, is not a change at all: stay bound
@@ -413,7 +413,7 @@ const ComposerInstance = forwardRef<
413
413
  chooseMenuItem(item);
414
414
  return true;
415
415
  };
416
- /** #3280 §2: Edit puts the queued text back in the draft and cancels the queue behind it (the wire
416
+ /** Edit puts the queued text back in the draft and cancels the queue behind it (the wire
417
417
  * has no per-message cancel — only a whole-queue clear). Offered only when exactly one prompt is
418
418
  * queued: with more than one, `cancel-queue` would still drop every one of them, but only the
419
419
  * shown prompt's text is known here, so "Edit" would silently lose the rest — the row offers only
@@ -432,7 +432,7 @@ const ComposerInstance = forwardRef<
432
432
  onDragLeave={onDragLeave}
433
433
  onDrop={onDrop}
434
434
  >
435
- {/* #3282 §4d rule 7: the drop target is announced even to someone who cannot drag a file —
435
+ {/* The drop target is announced even to someone who cannot drag a file —
436
436
  the attach button beside the textarea is how they reach the same result. */}
437
437
  <p id="composer-attach-hint" className="sr-only">
438
438
  Drag files here, or use Attach files, to add them to your message.
@@ -493,7 +493,7 @@ const ComposerInstance = forwardRef<
493
493
  className="gui-rise absolute bottom-full left-0 right-0 mb-2 max-h-[320px] overflow-y-auto rounded-2xl bg-popover p-1.5 shadow-2xl shadow-black/35"
494
494
  >
495
495
  {menu.map((item, index) => {
496
- // #3282 §4e: "Commands" then "Skills" — a header appears once, above the first row of
496
+ // "Commands" then "Skills" — a header appears once, above the first row of
497
497
  // its group (the menu always lists every command before every skill, so a group's rows
498
498
  // are contiguous).
499
499
  const showGroupHeader = index === 0 || menuGroupLabel(menu[index - 1]!) !== menuGroupLabel(item);
@@ -590,10 +590,10 @@ const ComposerInstance = forwardRef<
590
590
  value={draft}
591
591
  onChange={(e) => setDraft(e.target.value)}
592
592
  onKeyDown={(e) => {
593
- // #3280 §4: an Enter that only finishes an IME (Korean/Japanese/Chinese) composition must
593
+ // An Enter that only finishes an IME (Korean/Japanese/Chinese) composition must
594
594
  // not submit or accept the menu — `isComposing` (or `keyCode` 229, on browsers that
595
595
  // predate it) marks it; the keystroke is left alone so the browser commits the composition
596
- // normally, matching the guard the free-text prompt field uses (#3280 §3).
596
+ // normally, matching the guard the free-text prompt field uses.
597
597
  if (e.key === 'Enter' && (e.nativeEvent.isComposing || e.keyCode === 229)) return;
598
598
  if (menu) {
599
599
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
@@ -612,9 +612,9 @@ const ComposerInstance = forwardRef<
612
612
  return;
613
613
  }
614
614
  }
615
- // #3280 §2: Esc stops a running turn — but only once the menu (handled above) is out of
615
+ // Esc stops a running turn — but only once the menu (handled above) is out of
616
616
  // the way, so dismissing the `/` menu never doubles as an abort. Not while disconnected:
617
- // an abort could not reach the host either (issue #3280 §5).
617
+ // an abort could not reach the host either.
618
618
  if (e.key === 'Escape' && running && connected) {
619
619
  e.preventDefault();
620
620
  onStop();
@@ -632,7 +632,7 @@ const ComposerInstance = forwardRef<
632
632
  <button
633
633
  type="button"
634
634
  aria-label="Attach files"
635
- // #3282 §2 (part 2): the attach button follows the same disconnected rule as the status
635
+ // The attach button follows the same disconnected rule as the status
636
636
  // chips and the `/` menu — a short "Reconnecting…" tooltip in place of its usual one.
637
637
  title={connected ? 'Attach files' : 'Reconnecting…'}
638
638
  disabled={!connected}
@@ -690,7 +690,8 @@ export function GoalBar({
690
690
  onStop,
691
691
  }: {
692
692
  status: TSessionStatus | null;
693
- onStop: () => void;
693
+ /** Absent for an observer: the goal is shown without a Stop. */
694
+ onStop?: () => void;
694
695
  }): React.ReactElement | null {
695
696
  const goal = status?.goal;
696
697
  if (!goal || goal.status !== 'active') return null;
@@ -707,15 +708,17 @@ export function GoalBar({
707
708
  <span className="flex-shrink-0 text-[13px] tabular-nums text-subtle">
708
709
  {goal.iterations}/{goal.maxIterations}
709
710
  </span>
710
- <button
711
- type="button"
712
- aria-label="Stop goal"
713
- onClick={onStop}
714
- className="flex flex-shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
715
- >
716
- <Square size={11} fill="currentColor" aria-hidden="true" />
717
- Stop
718
- </button>
711
+ {onStop && (
712
+ <button
713
+ type="button"
714
+ aria-label="Stop goal"
715
+ onClick={onStop}
716
+ className="flex flex-shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
717
+ >
718
+ <Square size={11} fill="currentColor" aria-hidden="true" />
719
+ Stop
720
+ </button>
721
+ )}
719
722
  <span
720
723
  aria-hidden="true"
721
724
  className="absolute bottom-0 left-0 h-[2px] bg-accent/70 transition-[width]"
@@ -726,4 +729,4 @@ export function GoalBar({
726
729
  }
727
730
 
728
731
  // `StatusRow` (model/mode/effort pop-up menus) and its `ContextRing` moved to `StatusControls.tsx`
729
- // (#3282 §2 part 2) — imported above.
732
+ // — imported above.
@@ -44,7 +44,7 @@ function nodeText(node: React.ReactNode): string {
44
44
  }
45
45
 
46
46
  /**
47
- * "Copy code" / "Copy message" (#3289 §2): shows on hover and keyboard focus, always on touch (no
47
+ * "Copy code" / "Copy message": shows on hover and keyboard focus, always on touch (no
48
48
  * `hover` capability to gate on there). The transient "Copied" / "Couldn't copy" feedback lives in a
49
49
  * visually hidden `aria-live` region that is a SIBLING of the button, not a descendant of it — ARIA
50
50
  * name computation only ever looks at an element's own subtree, so a live region placed anywhere
@@ -96,7 +96,7 @@ function CopyButton({
96
96
  /**
97
97
  * A fenced code block: unwrapped and horizontally scrollable (a visible scrollbar via
98
98
  * `.gui-code-scroll`, `tabIndex=0` and a name so a keyboard user without a mouse wheel can still reach
99
- * the rest of a long line), with its own "Copy code" button revealed on hover/focus (#3289 §2).
99
+ * the rest of a long line), with its own "Copy code" button revealed on hover/focus.
100
100
  */
101
101
  function CodeBlockRenderer({ children }: { children?: React.ReactNode }): React.ReactElement {
102
102
  const text = nodeText(children).replace(/\n$/, '');
@@ -123,7 +123,7 @@ interface IConversationViewProps {
123
123
  activeTools: IActiveTool[];
124
124
  streamingText: string;
125
125
  isThinking: boolean;
126
- /** This connection's own driver id (§3289 §3), threaded to every message it renders. */
126
+ /** This connection's own driver id, threaded to every message it renders. */
127
127
  ownDriverId: TDriverId | null;
128
128
  }
129
129
 
@@ -207,11 +207,11 @@ function UserBlock({
207
207
  }: {
208
208
  content: string;
209
209
  author?: string;
210
- /** This connection's own driver id (§3289 §3): its own turns never carry a "from" label. */
210
+ /** This connection's own driver id: its own turns never carry a "from" label. */
211
211
  ownDriverId: TDriverId | null;
212
212
  }): React.ReactElement {
213
- // REMOTE-014 E5 (display-only, OWNER PRINCIPLE): show WHO drove this turn when it wasn't this
214
- // connection's own — in plain words, never the raw server-assigned id. #3288 §1: AGENT_DRIVER_ID
213
+ // Display-only: show WHO drove this turn when it wasn't this
214
+ // connection's own — in plain words, never the raw server-assigned id. AGENT_DRIVER_ID
215
215
  // (never "the same surface" — see isSameSurface) reads "Automatic — loop": today it is only ever
216
216
  // a loop's own injected turn (requestWakeup in interactive-session.ts).
217
217
  const coDriver =
@@ -239,7 +239,7 @@ function AgentBlock({
239
239
  {isStreaming && (
240
240
  <span className="ml-1 inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-foreground/70 align-middle" />
241
241
  )}
242
- {/* #3289 §2: every assistant message can copy its own Markdown source; a still-streaming one
242
+ {/* Every assistant message can copy its own Markdown source; a still-streaming one
243
243
  has nothing settled yet to copy, so the button waits for it to finish. */}
244
244
  {!isStreaming && (
245
245
  <div className="flex opacity-0 transition-opacity focus-within:opacity-100 group-hover/message:opacity-100 group-focus-within/message:opacity-100 [@media(hover:none)]:opacity-100">
@@ -264,7 +264,7 @@ function ToolIcon({ name, className }: { name: string; className?: string }): Re
264
264
  }
265
265
 
266
266
  /**
267
- * #3288: a long path's directory is shortened FROM THE LEFT so the filename — the part someone
267
+ * A long path's directory is shortened FROM THE LEFT so the filename — the part someone
268
268
  * actually needs to recognise — is never the part that gets cut.
269
269
  */
270
270
  const PATH_DISPLAY_MAX_CHARS = 52;
@@ -405,7 +405,7 @@ function ToolCard({ tool }: { tool: IActiveTool }): React.ReactElement {
405
405
  Boolean(parsedResult?.structured);
406
406
  const hasObservations = Boolean(tool.toolResultParts?.length);
407
407
  const expandable = hasDiff || hasOutput || hasObservations;
408
- // #3288: a projected `/command` tool shows its command, never the internal provider tool name.
408
+ // A projected `/command` tool shows its command, never the internal provider tool name.
409
409
  const label = tool.commandName ? `Ran /${tool.commandName}` : tool.name;
410
410
 
411
411
  return (
@@ -431,7 +431,7 @@ function ToolCard({ tool }: { tool: IActiveTool }): React.ReactElement {
431
431
  {label}
432
432
  </span>
433
433
  {tool.displayPath ? (
434
- // #3288: server-computed, workspace-relative, and never middle/end-truncated — only the
434
+ // Server-computed, workspace-relative, and never middle/end-truncated — only the
435
435
  // directory portion is shortened, from the left, so the filename always reads in full.
436
436
  <span
437
437
  className="min-w-0 flex-1 break-all font-mono text-[12.5px] text-subtle"
@@ -493,7 +493,7 @@ const COMMAND_TONE: Record<ICommandOutputEntry['tone'], { dot: string; text: str
493
493
  };
494
494
 
495
495
  /**
496
- * A slash command's outcome, where it was typed: line breaks kept, long output folded. #3289 §2 — the
496
+ * A slash command's outcome, where it was typed: line breaks kept, long output folded. The
497
497
  * card itself still marks the command (the `/name` header stays monospace, a fixed-width label rather
498
498
  * than prose), but its BODY reads as normal text — proportional, normal size — unless it is actually a
499
499
  * long listing (the same measure that decides whether to fold it: many raw lines, or tall once
@@ -554,7 +554,7 @@ function CommandCard({ entry }: { entry: ICommandOutputEntry }): React.ReactElem
554
554
  /** One line per finished turn's tool calls; the calls themselves open on demand. */
555
555
  function ToolGroup({ tools }: { tools: readonly IActiveTool[] }): React.ReactElement | null {
556
556
  const [open, setOpen] = useState(false);
557
- // #3288: an internal signal tool (e.g. the goal-status tool) is never shown as a call — the goal
557
+ // An internal signal tool (e.g. the goal-status tool) is never shown as a call — the goal
558
558
  // bar shows progress instead. A group made up ENTIRELY of internal tools renders nothing at all.
559
559
  const visible = tools.filter((tool) => !tool.internal);
560
560
  const failed = visible.filter((tool) => tool.status === 'error').length;
@@ -605,7 +605,7 @@ function ToolGroup({ tools }: { tools: readonly IActiveTool[] }): React.ReactEle
605
605
  );
606
606
  }
607
607
 
608
- /** #3288: a turn that changed files ends with this compact row — a click opens that file's diff. */
608
+ /** A turn that changed files ends with this compact row — a click opens that file's diff. */
609
609
  function ChangedFilesRow({ files }: { files: readonly IChangedFileSummary[] }): React.ReactElement {
610
610
  const [openPath, setOpenPath] = useState<string | null>(null);
611
611
  return (
@@ -671,7 +671,7 @@ export function ConversationView({
671
671
  };
672
672
 
673
673
  /**
674
- * #3289 §2 — follows new content only while the person is already at the bottom: streaming text, a
674
+ * Follows new content only while the person is already at the bottom: streaming text, a
675
675
  * finished message, a tool starting or finishing. Expanding something already on screen (a tool row,
676
676
  * a folded command card) touches none of these, so it never causes a jump — the effect simply does
677
677
  * not run for it. An instant jump rather than a smooth one, so it can never itself be mistaken by
@@ -3,9 +3,9 @@
3
3
  import React, { useEffect, useId, useRef } from 'react';
4
4
 
5
5
  /**
6
- * The shared modal-shell primitive (#3282 §4a, coordinated with #3282 §2's model/mode controls,
7
- * which also need a confirmation dialog). Whichever of the two lands first defines this file; the
8
- * other rebases onto it rather than shipping a second copy.
6
+ * The shared modal-shell primitive (the model/mode controls and the
7
+ * session-delete confirmation both need a confirmation dialog). Both
8
+ * consumers share it rather than shipping a second copy.
9
9
  *
10
10
  * `role="dialog"` (or `"alertdialog"`, see `role` below), `aria-modal`, a labelled title, focus moved
11
11
  * inside on open, a focus trap while open, Esc to close, and focus restored to whatever had it before
@@ -26,7 +26,7 @@ export interface IDialogProps {
26
26
  /** Id of an element describing the dialog, wired to `aria-describedby`. */
27
27
  describedById?: string;
28
28
  /**
29
- * Overrides what focus returns to when the dialog closes (#3289 §1). Default behavior captures
29
+ * Overrides what focus returns to when the dialog closes. Default behavior captures
30
30
  * `document.activeElement` when the dialog opens — which fails for a caller whose opener element
31
31
  * is removed from the DOM in the SAME update that opens this dialog (e.g. a menu item whose menu
32
32
  * closes as this dialog opens): by the time this dialog's open-effect runs, the browser has already
@@ -34,7 +34,7 @@ export interface IDialogProps {
34
34
  */
35
35
  restoreFocusTo?: HTMLElement | null;
36
36
  /**
37
- * #3282 §4e: `'alertdialog'` for a dialog that interrupts to demand a decision before anything else
37
+ * `'alertdialog'` for a dialog that interrupts to demand a decision before anything else
38
38
  * can happen (a destructive confirmation) — WAI-ARIA's distinction from a dialog the user can set
39
39
  * aside. Default `'dialog'`.
40
40
  */
@@ -182,7 +182,7 @@ export interface IConfirmDialogProps {
182
182
 
183
183
  /**
184
184
  * A title, a body, Cancel focused by default, and a confirm button that can be styled destructive.
185
- * #3282 §4e: `destructive` also picks the dialog's ARIA role — `"alertdialog"` when it is (an action
185
+ * `destructive` also picks the dialog's ARIA role — `"alertdialog"` when it is (an action
186
186
  * that demands a decision before anything else, e.g. deleting a session), `"dialog"` otherwise.
187
187
  */
188
188
  export function ConfirmDialog({
@@ -5,7 +5,7 @@ import React, { useState } from 'react';
5
5
  import type { IDiffLine } from '@robota-sdk/agent-interface-session';
6
6
 
7
7
  /**
8
- * #3288: renders a unified diff already built server-side (the ONE diff builder, in
8
+ * Renders a unified diff already built server-side (the ONE diff builder, in
9
9
  * `interactive-session-streaming.ts`) — a tool row's expanded result and the Edit/Write permission
10
10
  * prompt both show the SAME diff through this, so approving a change and reviewing it afterwards
11
11
  * look identical. This component never computes a diff itself, only displays one.
@@ -24,7 +24,7 @@ const DIFF_LINE_PREFIX: Record<IDiffLine['type'], string> = {
24
24
  hunk: '',
25
25
  };
26
26
 
27
- // #3288 review SHOULD 3: the server already caps each diff side at 500 lines — this is a second,
27
+ // The server already caps each diff side at 500 lines — this is a second,
28
28
  // independent fold so a diff that size never renders into the DOM at once either.
29
29
  const DIFF_FOLD_LINES = 40;
30
30
  const DIFF_EXPANDED_CAP_LINES = 500;
@@ -28,7 +28,7 @@ const RECORD_KIND_LABEL: Record<IExecutionDetailRecord['kind'], string> = {
28
28
  };
29
29
 
30
30
  /**
31
- * #3288 §1: opening an Agents panel entry — status, what it was asked (one line, from its
31
+ * Opening an Agents panel entry — status, what it was asked (one line, from its
32
32
  * headline), its transcript so far (paginated via `onLoadMore`) and, once it has one, its result.
33
33
  * `entry === null` renders nothing; the caller owns showing/hiding this over the panel.
34
34
  */