@robota-sdk/agent-ui-web 3.0.0-beta.94 → 3.0.0-beta.96
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 +100 -0
- package/dist/browser/index.d.ts +24 -3
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +641 -69
- package/dist/browser/index.js.map +1 -1
- package/dist/node/index.cjs +639 -67
- package/dist/node/index.d.cts +24 -3
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +24 -3
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +641 -69
- package/dist/node/index.js.map +1 -1
- package/docs/SPEC.md +9 -3
- package/package.json +6 -6
- package/src/app-view/bridge.ts +217 -0
- package/src/app-view/call-tool-result.ts +58 -0
- package/src/app-view/host-context.ts +37 -0
- package/src/app-view/types.ts +12 -0
- package/src/components/AppView.tsx +147 -0
- package/src/components/ConversationView.tsx +18 -1
- package/src/components/PermissionPrompt.tsx +108 -7
- package/src/components/SessionSurface.tsx +39 -12
- package/src/hooks/server-message-handling.ts +3 -0
- package/src/hooks/session-client-types.ts +14 -0
- package/src/hooks/use-app-view-requests.ts +178 -0
- package/src/hooks/useSessionClient.ts +28 -2
- package/src/index.ts +7 -1
|
@@ -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}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
|
|
3
|
+
import { AppViewHostContext } from '../app-view/host-context.js';
|
|
4
|
+
import { useProductIdentity } from '../product-identity.js';
|
|
3
5
|
import { AgentActivityPanel } from './AgentActivityPanel.js';
|
|
4
6
|
import { AgentSwitcherSheet } from './AgentSwitcherSheet.js';
|
|
5
7
|
import { ProductMark, ProductWordmark } from './Brand.js';
|
|
@@ -32,6 +34,8 @@ import type { IWsSessionState } from '../hooks/useSessionClient.js';
|
|
|
32
34
|
/** The column every part of the conversation shares, so messages, prompt and composer line up. */
|
|
33
35
|
const COLUMN = 'mx-auto w-full max-w-[760px] px-6';
|
|
34
36
|
|
|
37
|
+
const APP_VIEW_HOST_VERSION = '1.0.0';
|
|
38
|
+
|
|
35
39
|
/**
|
|
36
40
|
* Designed empty state shown before the first turn. Renders in the same slot as `ConversationView`
|
|
37
41
|
* (never together), so it carries the same `main` landmark and label (#3289 §3 review) — nothing
|
|
@@ -46,8 +50,8 @@ function EmptyState(): React.ReactElement {
|
|
|
46
50
|
What are we working on?
|
|
47
51
|
</h2>
|
|
48
52
|
<p className="text-[15px] leading-relaxed text-muted-foreground">
|
|
49
|
-
Session connected. Send a message to start — the agent works in the host runtime and
|
|
50
|
-
|
|
53
|
+
Session connected. Send a message to start — the agent works in the host runtime and asks
|
|
54
|
+
you here before anything that needs your permission.
|
|
51
55
|
</p>
|
|
52
56
|
</div>
|
|
53
57
|
</main>
|
|
@@ -107,6 +111,7 @@ export function SessionSurface({
|
|
|
107
111
|
pickFiles,
|
|
108
112
|
getPathForFile,
|
|
109
113
|
onOpenMemoryInEditor,
|
|
114
|
+
appViewSandboxUrl,
|
|
110
115
|
}: {
|
|
111
116
|
state: IWsSessionState;
|
|
112
117
|
surface?: string;
|
|
@@ -127,8 +132,21 @@ export function SessionSurface({
|
|
|
127
132
|
* itself is hidden (only shown "if the host supports it").
|
|
128
133
|
*/
|
|
129
134
|
onOpenMemoryInEditor?: (path: string) => void;
|
|
135
|
+
|
|
136
|
+
appViewSandboxUrl?: string;
|
|
130
137
|
}): React.ReactElement {
|
|
131
138
|
const [view, setView] = useState<TSessionView>('chat');
|
|
139
|
+
const { identity } = useProductIdentity();
|
|
140
|
+
const appViewHost = useMemo(
|
|
141
|
+
() => ({
|
|
142
|
+
...(appViewSandboxUrl !== undefined ? { sandboxUrl: appViewSandboxUrl } : {}),
|
|
143
|
+
readAppView: state.readAppView,
|
|
144
|
+
callAppTool: state.callAppTool,
|
|
145
|
+
hostInfo: { name: identity.displayName, version: APP_VIEW_HOST_VERSION },
|
|
146
|
+
platform: surface === 'app' ? ('desktop' as const) : ('web' as const),
|
|
147
|
+
}),
|
|
148
|
+
[appViewSandboxUrl, state.readAppView, state.callAppTool, identity.displayName, surface],
|
|
149
|
+
);
|
|
132
150
|
const composerRef = useRef<IComposerHandle>(null);
|
|
133
151
|
const tasks = state.executionWorkspace?.entries ?? [];
|
|
134
152
|
// The main thread alone is this conversation; the rail earns its width only for work beside it.
|
|
@@ -307,13 +325,15 @@ export function SessionSurface({
|
|
|
307
325
|
) : isEmpty ? (
|
|
308
326
|
<EmptyState />
|
|
309
327
|
) : (
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
328
|
+
<AppViewHostContext.Provider value={appViewHost}>
|
|
329
|
+
<ConversationView
|
|
330
|
+
messages={state.messages}
|
|
331
|
+
activeTools={state.activeTools}
|
|
332
|
+
streamingText={state.streamingText}
|
|
333
|
+
isThinking={state.isThinking}
|
|
334
|
+
ownDriverId={state.ownDriverId}
|
|
335
|
+
/>
|
|
336
|
+
</AppViewHostContext.Provider>
|
|
317
337
|
)}
|
|
318
338
|
</div>
|
|
319
339
|
<div
|
|
@@ -375,7 +395,10 @@ export function SessionSurface({
|
|
|
375
395
|
</div>
|
|
376
396
|
|
|
377
397
|
{hasWorkInProgress && (
|
|
378
|
-
<aside
|
|
398
|
+
<aside
|
|
399
|
+
aria-label="Agents"
|
|
400
|
+
className="flex w-72 flex-shrink-0 overflow-hidden bg-sidebar"
|
|
401
|
+
>
|
|
379
402
|
<AgentActivityPanel
|
|
380
403
|
tasks={tasks}
|
|
381
404
|
className="flex-1"
|
|
@@ -410,7 +433,11 @@ export function SessionSurface({
|
|
|
410
433
|
<SessionNotices state={state} />
|
|
411
434
|
<SettingsScreen state={state} />
|
|
412
435
|
<AgentSwitcherSheet state={state} />
|
|
413
|
-
<HelpSheet
|
|
436
|
+
<HelpSheet
|
|
437
|
+
open={state.helpOpen}
|
|
438
|
+
onClose={() => state.closeHelp()}
|
|
439
|
+
catalog={state.commandCatalog}
|
|
440
|
+
/>
|
|
414
441
|
</div>
|
|
415
442
|
);
|
|
416
443
|
}
|
|
@@ -62,6 +62,9 @@ export const SERVER_MESSAGE_HANDLING = {
|
|
|
62
62
|
project_status: 'reducer-state',
|
|
63
63
|
project_diff: 'reducer-state',
|
|
64
64
|
project_memory: 'reducer-state',
|
|
65
|
+
|
|
66
|
+
app_view: 'reducer-state',
|
|
67
|
+
app_tool_result: 'reducer-state',
|
|
65
68
|
background_task_event: 'intentionally-not-rendered',
|
|
66
69
|
background_job_group_event: 'intentionally-not-rendered',
|
|
67
70
|
plan_event: 'intentionally-not-rendered',
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
|
|
1
2
|
import type { TConnectionStatus, TClientMessage } from '../client/ws-session-client.js';
|
|
2
3
|
import type { TPendingPrompt } from './prompt-state.js';
|
|
3
4
|
import type { TActionResponse } from '@robota-sdk/agent-interface-transport';
|
|
4
5
|
import type {
|
|
5
6
|
IDiffLine,
|
|
7
|
+
IToolAppViewRef,
|
|
6
8
|
IToolState,
|
|
7
9
|
ISettingsSnapshot,
|
|
8
10
|
TDriverId,
|
|
@@ -16,6 +18,8 @@ import type {
|
|
|
16
18
|
} from '@robota-sdk/agent-interface-execution';
|
|
17
19
|
import type { IWireAgentDefinitionSummary, TServerMessage } from '@robota-sdk/agent-transport';
|
|
18
20
|
|
|
21
|
+
export type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
|
|
22
|
+
|
|
19
23
|
export type TPersonalUsageReport = Extract<
|
|
20
24
|
TServerMessage,
|
|
21
25
|
{ type: 'personal_usage_report' }
|
|
@@ -125,6 +129,8 @@ export interface IActiveTool {
|
|
|
125
129
|
commandName?: string;
|
|
126
130
|
/** #3288: true for an internal signal tool (e.g. goal-status) — never rendered as a call. */
|
|
127
131
|
internal?: boolean;
|
|
132
|
+
|
|
133
|
+
appView?: IToolAppViewRef;
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
/**
|
|
@@ -326,4 +332,12 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
|
|
|
326
332
|
* session notice) — no separate error state here.
|
|
327
333
|
*/
|
|
328
334
|
deleteSchedule: (taskId: string) => void;
|
|
335
|
+
|
|
336
|
+
readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
|
|
337
|
+
callAppTool: (
|
|
338
|
+
viewKey: string,
|
|
339
|
+
name: string,
|
|
340
|
+
args: TAppToolArguments,
|
|
341
|
+
signal?: AbortSignal,
|
|
342
|
+
) => Promise<TAppToolOutcome>;
|
|
329
343
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { TClientMessage } from '../client/ws-session-client.js';
|
|
4
|
+
import type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
|
|
5
|
+
import type { IActiveTool } from './session-client-types.js';
|
|
6
|
+
import type { TServerMessage } from '@robota-sdk/agent-transport';
|
|
7
|
+
|
|
8
|
+
type TPendingRequest = {
|
|
9
|
+
reject: (error: Error) => void;
|
|
10
|
+
dispose: () => void;
|
|
11
|
+
} & (
|
|
12
|
+
| { answer: 'app_view'; resolve: (value: TAppView) => void }
|
|
13
|
+
| { answer: 'app_tool_result'; resolve: (value: TAppToolOutcome) => void }
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
let requestCounter = 0;
|
|
17
|
+
function nextRequestId(prefix: string): string {
|
|
18
|
+
requestCounter += 1;
|
|
19
|
+
return `${prefix}_${requestCounter}_${Date.now()}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CONNECTION_CLOSED = 'The connection to the session closed.';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The key a finished call's app view is read and called by, taken from the tool row's `appView`
|
|
26
|
+
* reference; `undefined` when the row gives none. Everything outside this module treats it as an
|
|
27
|
+
* opaque string — it is read here and put on the two wire messages below, nowhere else.
|
|
28
|
+
*/
|
|
29
|
+
export function appViewKey(tool: IActiveTool): string | undefined {
|
|
30
|
+
return tool.appView?.viewId;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readAppViewMessage(requestId: string, viewKey: string): TClientMessage {
|
|
34
|
+
return { type: 'read-app-view', requestId, viewId: viewKey };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function callAppToolMessage(
|
|
38
|
+
requestId: string,
|
|
39
|
+
viewKey: string,
|
|
40
|
+
name: string,
|
|
41
|
+
args: TAppToolArguments,
|
|
42
|
+
): TClientMessage {
|
|
43
|
+
return { type: 'call-app-tool', requestId, viewId: viewKey, name, arguments: args };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The app-view host's two requests to the session, each a promise settled by the reply that echoes
|
|
48
|
+
* its `requestId` (`app_view` / `app_tool_result`, or a `protocol_error` naming it). Unlike the
|
|
49
|
+
* panels' latest-wins state, several can be outstanding at once (one per frame on screen), so each
|
|
50
|
+
* keeps its own entry. A reply that lost its connection never arrives: the owner of the connection
|
|
51
|
+
* rejects every outstanding request when it drops, is replaced or goes away (`rejectPendingAppRequests`).
|
|
52
|
+
*/
|
|
53
|
+
export function useAppViewRequests(
|
|
54
|
+
send: (msg: TClientMessage) => void,
|
|
55
|
+
isConnected: () => boolean,
|
|
56
|
+
): {
|
|
57
|
+
readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
|
|
58
|
+
callAppTool: (
|
|
59
|
+
viewKey: string,
|
|
60
|
+
name: string,
|
|
61
|
+
args: TAppToolArguments,
|
|
62
|
+
signal?: AbortSignal,
|
|
63
|
+
) => Promise<TAppToolOutcome>;
|
|
64
|
+
handleAppViewMessage: (msg: TServerMessage) => boolean;
|
|
65
|
+
rejectPendingAppRequests: (reason?: string) => void;
|
|
66
|
+
} {
|
|
67
|
+
const pendingRef = useRef(new Map<string, TPendingRequest>());
|
|
68
|
+
const [requestOwner] = useState(() => nextRequestId('app_request'));
|
|
69
|
+
|
|
70
|
+
const rejectPendingAppRequests = useCallback((reason: string = CONNECTION_CLOSED): void => {
|
|
71
|
+
const pending = [...pendingRef.current.values()];
|
|
72
|
+
pendingRef.current.clear();
|
|
73
|
+
for (const request of pending) {
|
|
74
|
+
request.dispose();
|
|
75
|
+
request.reject(new Error(reason));
|
|
76
|
+
}
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const request = useCallback(
|
|
80
|
+
<T extends TAppView | TAppToolOutcome>(
|
|
81
|
+
prefix: string,
|
|
82
|
+
message: (requestId: string) => TClientMessage,
|
|
83
|
+
pending: (
|
|
84
|
+
resolve: (value: T) => void,
|
|
85
|
+
reject: (error: Error) => void,
|
|
86
|
+
dispose: () => void,
|
|
87
|
+
) => TPendingRequest,
|
|
88
|
+
signal?: AbortSignal,
|
|
89
|
+
): Promise<T> =>
|
|
90
|
+
new Promise<T>((resolve, reject) => {
|
|
91
|
+
if (!isConnected() || signal?.aborted) {
|
|
92
|
+
reject(new Error(signal?.aborted ? 'The app view closed.' : CONNECTION_CLOSED));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const requestId = nextRequestId(`${requestOwner}_${prefix}`);
|
|
96
|
+
const dispose = (): void => signal?.removeEventListener('abort', abort);
|
|
97
|
+
const abort = (): void => {
|
|
98
|
+
pendingRef.current.delete(requestId);
|
|
99
|
+
dispose();
|
|
100
|
+
reject(new Error('The app view closed.'));
|
|
101
|
+
};
|
|
102
|
+
pendingRef.current.set(requestId, pending(resolve, reject, dispose));
|
|
103
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
104
|
+
try {
|
|
105
|
+
send(message(requestId));
|
|
106
|
+
} catch (error: unknown) {
|
|
107
|
+
pendingRef.current.delete(requestId);
|
|
108
|
+
dispose();
|
|
109
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
110
|
+
}
|
|
111
|
+
}),
|
|
112
|
+
[isConnected, requestOwner, send],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const readAppView = useCallback(
|
|
116
|
+
(viewKey: string, signal?: AbortSignal): Promise<TAppView> =>
|
|
117
|
+
request<TAppView>(
|
|
118
|
+
'app_view',
|
|
119
|
+
(requestId) => readAppViewMessage(requestId, viewKey),
|
|
120
|
+
(resolve, reject, dispose) => ({ answer: 'app_view', resolve, reject, dispose }),
|
|
121
|
+
signal,
|
|
122
|
+
),
|
|
123
|
+
[request],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const callAppTool = useCallback(
|
|
127
|
+
(
|
|
128
|
+
viewKey: string,
|
|
129
|
+
name: string,
|
|
130
|
+
args: TAppToolArguments,
|
|
131
|
+
signal?: AbortSignal,
|
|
132
|
+
): Promise<TAppToolOutcome> =>
|
|
133
|
+
request<TAppToolOutcome>(
|
|
134
|
+
'app_tool',
|
|
135
|
+
(requestId) => callAppToolMessage(requestId, viewKey, name, args),
|
|
136
|
+
(resolve, reject, dispose) => ({ answer: 'app_tool_result', resolve, reject, dispose }),
|
|
137
|
+
signal,
|
|
138
|
+
),
|
|
139
|
+
[request],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
/** True when the message answered one of this hook's requests; every other message is not its own. */
|
|
143
|
+
const handleAppViewMessage = useCallback(
|
|
144
|
+
(msg: TServerMessage): boolean => {
|
|
145
|
+
if (msg.type === 'app_view' || msg.type === 'app_tool_result') {
|
|
146
|
+
const pending = pendingRef.current.get(msg.requestId);
|
|
147
|
+
// A reply to a request that already settled (or never was ours) matches nothing.
|
|
148
|
+
if (pending === undefined || pending.answer !== msg.type) return true;
|
|
149
|
+
pendingRef.current.delete(msg.requestId);
|
|
150
|
+
pending.dispose();
|
|
151
|
+
if (msg.type === 'app_view' && pending.answer === 'app_view') {
|
|
152
|
+
pending.resolve(msg.view);
|
|
153
|
+
} else if (msg.type === 'app_tool_result' && pending.answer === 'app_tool_result') {
|
|
154
|
+
const { success, data, parts, error } = msg;
|
|
155
|
+
pending.resolve({
|
|
156
|
+
success,
|
|
157
|
+
...(data !== undefined ? { data } : {}),
|
|
158
|
+
...(parts !== undefined ? { parts } : {}),
|
|
159
|
+
...(error !== undefined ? { error } : {}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (msg.type === 'protocol_error' && msg.requestId !== undefined) {
|
|
165
|
+
const pending = pendingRef.current.get(msg.requestId);
|
|
166
|
+
if (pending === undefined) return msg.requestId.startsWith(`${requestOwner}_`);
|
|
167
|
+
pendingRef.current.delete(msg.requestId);
|
|
168
|
+
pending.dispose();
|
|
169
|
+
pending.reject(new Error(msg.message));
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
},
|
|
174
|
+
[requestOwner],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return { readAppView, callAppTool, handleAppViewMessage, rejectPendingAppRequests };
|
|
178
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { createWsSessionClient } from '../client/ws-session-client.js';
|
|
24
24
|
import { SERVER_MESSAGE_HANDLING } from './server-message-handling.js';
|
|
25
25
|
import { useAgentSwitcherState } from './use-agent-switcher-state.js';
|
|
26
|
+
import { useAppViewRequests } from './use-app-view-requests.js';
|
|
26
27
|
import { useExecutionDetailState } from './use-execution-detail.js';
|
|
27
28
|
import { useModelListState } from './use-model-list.js';
|
|
28
29
|
import { usePersonalUsageState } from './use-personal-usage.js';
|
|
@@ -153,7 +154,7 @@ function toolEndFields(
|
|
|
153
154
|
state: IToolState,
|
|
154
155
|
): Pick<
|
|
155
156
|
IActiveTool,
|
|
156
|
-
'status' | 'result' | 'diffLines' | 'diffFile' | 'toolResultData' | 'toolResultParts'
|
|
157
|
+
'status' | 'result' | 'diffLines' | 'diffFile' | 'toolResultData' | 'toolResultParts' | 'appView'
|
|
157
158
|
> {
|
|
158
159
|
return {
|
|
159
160
|
// A tool that finished with an error result renders failed (ToolCard/ToolGroup key off
|
|
@@ -168,6 +169,7 @@ function toolEndFields(
|
|
|
168
169
|
...(state.diffFile ? { diffFile: state.diffFile } : {}),
|
|
169
170
|
...(state.toolResultData !== undefined ? { toolResultData: state.toolResultData } : {}),
|
|
170
171
|
...(state.toolResultParts ? { toolResultParts: state.toolResultParts } : {}),
|
|
172
|
+
...(state.appView ? { appView: state.appView } : {}),
|
|
171
173
|
};
|
|
172
174
|
}
|
|
173
175
|
|
|
@@ -268,6 +270,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
268
270
|
makeClient: TMakeSessionClient<TStatus>,
|
|
269
271
|
): IWsSessionState<TStatus> {
|
|
270
272
|
const [status, setStatus] = useState<TStatus>('disconnected' as TStatus);
|
|
273
|
+
// The app-view requests read this to refuse at once when no connection exists to answer them.
|
|
274
|
+
const statusRef = useRef<TStatus>('disconnected' as TStatus);
|
|
271
275
|
const [messages, setMessages] = useState<TConversationEntry[]>([]);
|
|
272
276
|
const [activeTools, setActiveTools] = useState<IActiveTool[]>([]);
|
|
273
277
|
const [streamingText, setStreamingText] = useState('');
|
|
@@ -433,6 +437,9 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
433
437
|
useExecutionDetailState(send);
|
|
434
438
|
const { handleModelListMessage, ...modelListState } = useModelListState(send);
|
|
435
439
|
const { handleProjectMessage, ...projectPanelState } = useProjectPanelState(send);
|
|
440
|
+
const isConnected = useCallback((): boolean => statusRef.current === 'connected', []);
|
|
441
|
+
const { handleAppViewMessage, rejectPendingAppRequests, readAppView, callAppTool } =
|
|
442
|
+
useAppViewRequests(send, isConnected);
|
|
436
443
|
const {
|
|
437
444
|
handleSessionsMessage,
|
|
438
445
|
canListSessions,
|
|
@@ -466,6 +473,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
466
473
|
if (handleExecutionDetailMessage(msg)) return;
|
|
467
474
|
if (handleModelListMessage(msg)) return;
|
|
468
475
|
if (handleProjectMessage(msg)) return;
|
|
476
|
+
// A reply to a request an app view's host made (the view's document, or a tool it called).
|
|
477
|
+
if (handleAppViewMessage(msg)) return;
|
|
469
478
|
if (handleSessionsMessage(msg)) return;
|
|
470
479
|
if (handleSettingsMessage(msg)) return;
|
|
471
480
|
if (handleAgentDefinitionsMessage(msg)) return;
|
|
@@ -649,6 +658,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
649
658
|
}
|
|
650
659
|
// #3189: the host made another session current — drop what this one showed, re-read it all.
|
|
651
660
|
case 'session_switched': {
|
|
661
|
+
rejectPendingAppRequests('The active session changed.');
|
|
652
662
|
settleSessionChange();
|
|
653
663
|
// Nothing of the old session stays on screen until the new one's answers arrive.
|
|
654
664
|
sessionStatusRef.current = null;
|
|
@@ -675,6 +685,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
675
685
|
break;
|
|
676
686
|
}
|
|
677
687
|
case 'history_cleared': {
|
|
688
|
+
rejectPendingAppRequests('The session history was cleared.');
|
|
678
689
|
streamingTextRef.current = '';
|
|
679
690
|
turnSegmentsRef.current = [];
|
|
680
691
|
setStreamingText('');
|
|
@@ -868,6 +879,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
868
879
|
closeExecutionDetail,
|
|
869
880
|
finishTurn,
|
|
870
881
|
handleAgentDefinitionsMessage,
|
|
882
|
+
handleAppViewMessage,
|
|
871
883
|
handleExecutionDetailMessage,
|
|
872
884
|
handleModelListMessage,
|
|
873
885
|
handleProjectMessage,
|
|
@@ -882,6 +894,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
882
894
|
requestSchedules,
|
|
883
895
|
requestSessions,
|
|
884
896
|
resolveSwitchResult,
|
|
897
|
+
rejectPendingAppRequests,
|
|
885
898
|
send,
|
|
886
899
|
setSessionSidebarOpen,
|
|
887
900
|
settleSessionChange,
|
|
@@ -905,7 +918,10 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
905
918
|
|
|
906
919
|
useEffect(() => {
|
|
907
920
|
const onStatusChange = (next: TStatus): void => {
|
|
921
|
+
statusRef.current = next;
|
|
908
922
|
setStatus(next);
|
|
923
|
+
// A reply lost with the connection never arrives; the views waiting on one learn that now.
|
|
924
|
+
if (next !== 'connected') rejectPendingAppRequests();
|
|
909
925
|
// Every client learns what it can offer and what is in effect as soon as it is attached.
|
|
910
926
|
if (next === 'connected') {
|
|
911
927
|
// A reply lost with the old connection never arrives; stop waiting for it.
|
|
@@ -933,8 +949,16 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
933
949
|
return () => {
|
|
934
950
|
client.disconnect();
|
|
935
951
|
clientRef.current = null;
|
|
952
|
+
rejectPendingAppRequests();
|
|
936
953
|
};
|
|
937
|
-
}, [
|
|
954
|
+
}, [
|
|
955
|
+
makeClient,
|
|
956
|
+
handleMessage,
|
|
957
|
+
requestSessions,
|
|
958
|
+
requestSchedules,
|
|
959
|
+
armRestore,
|
|
960
|
+
rejectPendingAppRequests,
|
|
961
|
+
]);
|
|
938
962
|
|
|
939
963
|
return {
|
|
940
964
|
status,
|
|
@@ -966,6 +990,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
966
990
|
openAgentSwitcher,
|
|
967
991
|
...agentSwitcherState,
|
|
968
992
|
...schedulesState,
|
|
993
|
+
readAppView,
|
|
994
|
+
callAppTool,
|
|
969
995
|
helpOpen,
|
|
970
996
|
openHelp,
|
|
971
997
|
closeHelp,
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,13 @@ export type {
|
|
|
16
16
|
ISessionClientHandle,
|
|
17
17
|
TMakeSessionClient,
|
|
18
18
|
} from './hooks/useSessionClient.js';
|
|
19
|
-
export type {
|
|
19
|
+
export type {
|
|
20
|
+
TAppToolArguments,
|
|
21
|
+
TAppToolOutcome,
|
|
22
|
+
TAppView,
|
|
23
|
+
TSessionListing,
|
|
24
|
+
TSessionsError,
|
|
25
|
+
} from './hooks/session-client-types.js';
|
|
20
26
|
// Issue #3280 §5: a host that can restart the runtime calls this before reloading the page, so the
|
|
21
27
|
// first session listing after reconnecting can switch back to the session the person was in.
|
|
22
28
|
export { rememberSessionForRestore } from './hooks/use-session-directory.js';
|