@robota-sdk/agent-ui-web 3.0.0-beta.93 → 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/CHANGELOG.md +94 -0
- package/dist/browser/index.d.ts +6 -0
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +91 -16
- package/dist/browser/index.js.map +1 -1
- package/dist/node/index.cjs +90 -15
- package/dist/node/index.d.cts +6 -0
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +6 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +91 -16
- package/dist/node/index.js.map +1 -1
- package/docs/SPEC.md +2 -1
- package/package.json +6 -6
- package/src/components/PermissionPrompt.tsx +108 -7
- package/src/hooks/server-message-handling.ts +4 -0
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.
|
|
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.
|
|
62
|
-
"@robota-sdk/agent-interface-
|
|
63
|
-
"@robota-sdk/agent-interface-
|
|
64
|
-
"@robota-sdk/agent-
|
|
65
|
-
"@robota-sdk/agent-
|
|
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)
|
|
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
|
|
263
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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',
|