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

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.
package/docs/SPEC.md CHANGED
@@ -67,7 +67,8 @@ does not own session lifecycle, conversation history, or agent runtime state.
67
67
  host has said one; the desktop window follows the page title.
68
68
  - A pending permission or ask prompt is visible whatever view is open, because a gated turn waits
69
69
  on it. It never takes focus from a field the person is typing in, and its keys answer it only once
70
- it has that focus; a click answers at once.
70
+ it has that focus; a click answers at once, except on a multi-select ask, whose options toggle
71
+ until it is submitted.
71
72
  - Nothing typed is lost to a lost connection: while the transport is not `connected`, the composer
72
73
  keeps its draft and refuses to submit it, and a banner above the conversation — never a full-screen
73
74
  replacement — says so: retrying while attempts continue, and once they give up, either the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-ui-web",
3
- "version": "3.0.0-beta.94",
3
+ "version": "3.0.0-beta.95",
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",
@@ -58,11 +58,11 @@
58
58
  "pretendard": "^1.3.9",
59
59
  "react-markdown": "^10.1.0",
60
60
  "remark-gfm": "^4.0.1",
61
- "@robota-sdk/agent-interface-command": "3.0.0-beta.94",
62
- "@robota-sdk/agent-interface-session": "3.0.0-beta.94",
63
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.94",
64
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.94",
65
- "@robota-sdk/agent-transport": "3.0.0-beta.94"
61
+ "@robota-sdk/agent-interface-command": "3.0.0-beta.95",
62
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.95",
63
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.95",
64
+ "@robota-sdk/agent-transport": "3.0.0-beta.95",
65
+ "@robota-sdk/agent-interface-execution": "3.0.0-beta.95"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "react": ">=18"
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { MessageCircleQuestion, ShieldAlert } from 'lucide-react';
3
+ import { MessageCircleQuestion, ShieldAlert, Square, SquareCheck } from 'lucide-react';
4
4
  import React, {
5
5
  forwardRef,
6
6
  useEffect,
@@ -32,6 +32,12 @@ import type { TDriverId } from '@robota-sdk/agent-interface-session';
32
32
  * lives only in `FreeTextField`, keyed by `prompt.id`: a cancelled or answered prompt's text can
33
33
  * never reach the next prompt's render, masked or not, because the field is a fresh component
34
34
  * instance rather than a value carried over in this component's own state (#3280 §3 follow-up).
35
+ *
36
+ * An ask whose `maxSelect` is above 1 is a multi-select: each option is a toggle (a click or 1–9 ticks
37
+ * it, nothing answers yet), and Submit — or Enter at the prompt, once the selection is allowed —
38
+ * answers with the ticked values in option order. Unlike the free-text field, the ticks live in this
39
+ * component, because the dock's keys act on them; they are tagged with `prompt.id`, so a prompt never
40
+ * reads the ticks of another.
35
41
  */
36
42
  interface IPermissionPromptProps {
37
43
  prompts: readonly TPendingPrompt[];
@@ -77,6 +83,42 @@ function isDestructiveAskOption(requestId: string | undefined, value: string): b
77
83
  return requestId === 'provider-profile-action' && value === 'delete';
78
84
  }
79
85
 
86
+ type TAskRequest = Extract<TPendingPrompt, { kind: 'ask' }>['request'];
87
+ type TAskOption = NonNullable<TAskRequest['options']>[number];
88
+
89
+ /** What a multi-select ask (`maxSelect` above 1) lets the person tick; see `readMultiSelect`. */
90
+ interface IMultiSelect {
91
+ min: number;
92
+ max: number;
93
+ /** The options that can be ticked, in the order the answer lists them. */
94
+ options: readonly TAskOption[];
95
+ /** The ticks a prompt starts with. */
96
+ defaults: ReadonlySet<string>;
97
+ }
98
+
99
+ /**
100
+ * The multi-select shape of an ask, or undefined for every other ask (which answers on click). The
101
+ * rules mirror the terminal checklist (`MultiSelectList` in agent-ui-terminal): `minSelect` (default 1)
102
+ * gates the submit, and `maxSelect` caps the ticks — a toggle beyond the cap is ignored, never swapped
103
+ * for the oldest tick. `default.values` pre-select; a default that names no option is dropped, and
104
+ * more defaults than the cap keep the first ones in option order, so the answer never breaks the cap.
105
+ */
106
+ function readMultiSelect(request: TAskRequest): IMultiSelect | undefined {
107
+ const max = request.maxSelect ?? 1;
108
+ if (max <= 1) return undefined;
109
+ const options = (request.options ?? []).filter(
110
+ (opt) => !isDestructiveAskOption(request.id, opt.value),
111
+ );
112
+ const wanted = new Set(request.default?.values ?? []);
113
+ const defaults = options.filter((opt) => wanted.has(opt.value)).slice(0, max);
114
+ return {
115
+ min: request.minSelect ?? 1,
116
+ max,
117
+ options,
118
+ defaults: new Set(defaults.map((opt) => opt.value)),
119
+ };
120
+ }
121
+
80
122
  export function PermissionPrompt({
81
123
  prompts,
82
124
  onAnswerPermission,
@@ -106,6 +148,11 @@ export function PermissionPrompt({
106
148
  // Set at the moment of an answer if focus was inside the prompt then; consumed once the prompt list
107
149
  // empties (see below) or cleared once a next prompt shows it was not needed after all.
108
150
  const pendingFocusReturnRef = useRef(false);
151
+ // A multi-select ask's ticks, tagged with the prompt they belong to: another prompt (a different
152
+ // `prompt.id`) reads its own defaults instead, so there is no stale selection to reset.
153
+ const [selection, setSelection] = useState<
154
+ { promptId: string; values: ReadonlySet<string> } | undefined
155
+ >(undefined);
109
156
  useEffect(() => {
110
157
  if (promptId === undefined || armDelayMs <= 0) return undefined;
111
158
  const timer = setTimeout(() => setArmedId(promptId), armDelayMs);
@@ -200,6 +247,23 @@ export function PermissionPrompt({
200
247
  fieldHandleRef.current?.clear();
201
248
  onAnswerAsk(id, response);
202
249
  };
250
+ const multi = prompt.kind === 'ask' ? readMultiSelect(prompt.request) : undefined;
251
+ const ticks = selection?.promptId === prompt.id ? selection.values : multi?.defaults;
252
+ const selectedValues =
253
+ multi && ticks ? multi.options.map((opt) => opt.value).filter((value) => ticks.has(value)) : [];
254
+ const canSubmit = multi !== undefined && selectedValues.length >= multi.min;
255
+ const toggleOption = (value: string): void => {
256
+ if (!multi) return;
257
+ setSelection((current) => {
258
+ const next = new Set(current?.promptId === prompt.id ? current.values : multi.defaults);
259
+ if (next.has(value)) next.delete(value);
260
+ else if (next.size < multi.max) next.add(value);
261
+ return { promptId: prompt.id, values: next };
262
+ });
263
+ };
264
+ const submitSelection = (): void => {
265
+ if (canSubmit) answerAsk(prompt.id, { type: 'answer', values: selectedValues });
266
+ };
203
267
  /** A click anywhere in the prompt that isn't on a button or the field itself focuses the field. */
204
268
  const onContainerClick = (event: React.MouseEvent<HTMLDivElement>): void => {
205
269
  if (!hasFreeText) return;
@@ -222,6 +286,15 @@ export function PermissionPrompt({
222
286
  }
223
287
  // A key that reaches a prompt still arming (it kept focus from the one before) is dropped.
224
288
  if (!armed) return;
289
+ if (multi && event.key === 'Enter') {
290
+ // Only from the prompt itself: on a focused button (a toggle, Submit, Cancel) Enter is that
291
+ // button's own click, and its keydown reaches this handler as well.
292
+ if (event.target === event.currentTarget) {
293
+ event.preventDefault();
294
+ submitSelection();
295
+ }
296
+ return;
297
+ }
225
298
  const index = Number(event.key) - 1;
226
299
  if (!Number.isInteger(index) || index < 0) return;
227
300
  if (prompt.kind === 'permission') {
@@ -235,7 +308,10 @@ export function PermissionPrompt({
235
308
  (opt) => !isDestructiveAskOption(prompt.request.id, opt.value),
236
309
  );
237
310
  const option = digitOptions[index];
238
- if (option) answerAsk(prompt.id, { type: 'answer', values: [option.value] });
311
+ if (!option) return;
312
+ // A multi-select's digit ticks the option; it answers only when the ask has a single answer.
313
+ if (multi) toggleOption(option.value);
314
+ else answerAsk(prompt.id, { type: 'answer', values: [option.value] });
239
315
  };
240
316
  // #3288: an Edit/Write request carries the same server-built diff `tool_end` would show.
241
317
  const hasDiff = prompt.kind === 'permission' && (prompt.diffLines?.length ?? 0) > 0;
@@ -258,9 +334,11 @@ export function PermissionPrompt({
258
334
  const askArmedHint =
259
335
  hasFreeText && fieldFocused
260
336
  ? 'Enter to submit · Esc to cancel'
261
- : askOptions.length > 0
262
- ? '1–9 choose · Esc cancel'
263
- : 'Esc cancels';
337
+ : multi && askOptions.length > 0
338
+ ? '1–9 toggle · Enter submit · Esc cancel'
339
+ : askOptions.length > 0
340
+ ? '1–9 choose · Esc cancel'
341
+ : 'Esc cancels';
264
342
  return (
265
343
  <div
266
344
  className={
@@ -397,11 +475,24 @@ export function PermissionPrompt({
397
475
  <button
398
476
  type="button"
399
477
  aria-keyshortcuts={dock && index < 9 ? String(index + 1) : undefined}
400
- className={index === 0 ? PRIMARY_BUTTON : SECONDARY_BUTTON}
478
+ aria-pressed={multi ? selectedValues.includes(opt.value) : undefined}
479
+ className={
480
+ (multi ? selectedValues.includes(opt.value) : index === 0)
481
+ ? PRIMARY_BUTTON
482
+ : SECONDARY_BUTTON
483
+ }
401
484
  onClick={onButton(() =>
402
- answerAsk(prompt.id, { type: 'answer', values: [opt.value] }),
485
+ multi
486
+ ? toggleOption(opt.value)
487
+ : answerAsk(prompt.id, { type: 'answer', values: [opt.value] }),
403
488
  )}
404
489
  >
490
+ {multi &&
491
+ (selectedValues.includes(opt.value) ? (
492
+ <SquareCheck size={15} strokeWidth={2} />
493
+ ) : (
494
+ <Square size={15} strokeWidth={2} />
495
+ ))}
405
496
  <span>{opt.label}</span>
406
497
  {dock && index < 9 && <Kbd>{index + 1}</Kbd>}
407
498
  </button>
@@ -454,6 +545,16 @@ export function PermissionPrompt({
454
545
  </div>
455
546
  )}
456
547
  <div className="flex flex-wrap items-center gap-2">
548
+ {multi && (
549
+ <button
550
+ type="button"
551
+ className={`${PRIMARY_BUTTON} disabled:opacity-60`}
552
+ disabled={!canSubmit}
553
+ onClick={onButton(submitSelection)}
554
+ >
555
+ {canSubmit ? 'Submit' : `Submit (min ${multi.min})`}
556
+ </button>
557
+ )}
457
558
  <button
458
559
  type="button"
459
560
  className={GHOST_BUTTON}
@@ -62,6 +62,10 @@ export const SERVER_MESSAGE_HANDLING = {
62
62
  project_status: 'reducer-state',
63
63
  project_diff: 'reducer-state',
64
64
  project_memory: 'reducer-state',
65
+ // The app-view host (the next step) consumes both: the document a finished call named, and the
66
+ // outcome of a tool call it made for that view.
67
+ app_view: 'intentionally-not-rendered',
68
+ app_tool_result: 'intentionally-not-rendered',
65
69
  background_task_event: 'intentionally-not-rendered',
66
70
  background_job_group_event: 'intentionally-not-rendered',
67
71
  plan_event: 'intentionally-not-rendered',