@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.
@@ -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}
@@ -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
- asks you here before anything that needs your permission.
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
- <ConversationView
311
- messages={state.messages}
312
- activeTools={state.activeTools}
313
- streamingText={state.streamingText}
314
- isThinking={state.isThinking}
315
- ownDriverId={state.ownDriverId}
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 aria-label="Agents" className="flex w-72 flex-shrink-0 overflow-hidden bg-sidebar">
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 open={state.helpOpen} onClose={() => state.closeHelp()} catalog={state.commandCatalog} />
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
- }, [makeClient, handleMessage, requestSessions, requestSchedules, armRestore]);
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 { TSessionListing, TSessionsError } from './hooks/session-client-types.js';
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';