@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
package/docs/SPEC.md
CHANGED
|
@@ -34,8 +34,13 @@ does not own session lifecycle, conversation history, or agent runtime state.
|
|
|
34
34
|
Session and protocol failures are also toasts, beside the conversation rather than in it.
|
|
35
35
|
- A tool call's diff or output is server-computed and shown on demand, preserving its observation
|
|
36
36
|
types, call identity and success or denial/failure through live delivery and replay. Resource references remain inert because
|
|
37
|
-
displaying a contributor's URI grants no read authority;
|
|
38
|
-
|
|
37
|
+
displaying a contributor's URI grants no read authority; the one exception is a view the call's own
|
|
38
|
+
server declared for a model call, read on demand through its session-owned view authority. The host
|
|
39
|
+
admits the document only in an opaque frame with enforced native network isolation; the bridge
|
|
40
|
+
reaches only that view's declared tools through the session's permission gate. Replacing a view
|
|
41
|
+
or its session revokes its pending local requests. A surface without a suitable sandbox retains
|
|
42
|
+
the text result and explains why the app is unavailable.
|
|
43
|
+
Unsupported media is explained rather than silently discarded. Output is never recomputed here or buried behind a truncated path or argument; an internal signal tool never renders as a call,
|
|
39
44
|
and a projected `/command` tool shows the command it ran, not the provider-facing tool name.
|
|
40
45
|
- A UI-intent opens its GUI screen when there is one — the session picker is the session sidebar
|
|
41
46
|
(unless the host cannot list sessions), settings and the plugin manager both open the Settings
|
|
@@ -67,7 +72,8 @@ does not own session lifecycle, conversation history, or agent runtime state.
|
|
|
67
72
|
host has said one; the desktop window follows the page title.
|
|
68
73
|
- A pending permission or ask prompt is visible whatever view is open, because a gated turn waits
|
|
69
74
|
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
|
|
75
|
+
it has that focus; a click answers at once, except on a multi-select ask, whose options toggle
|
|
76
|
+
until it is submitted.
|
|
71
77
|
- Nothing typed is lost to a lost connection: while the transport is not `connected`, the composer
|
|
72
78
|
keeps its draft and refuses to submit it, and a banner above the conversation — never a full-screen
|
|
73
79
|
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.96",
|
|
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-
|
|
64
|
-
"@robota-sdk/agent-interface-
|
|
65
|
-
"@robota-sdk/agent-
|
|
61
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.96",
|
|
62
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.96",
|
|
63
|
+
"@robota-sdk/agent-transport": "3.0.0-beta.96",
|
|
64
|
+
"@robota-sdk/agent-interface-transport": "3.0.0-beta.96",
|
|
65
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.96"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"react": ">=18"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { outcomeToCallToolResult, type ICallToolResult } from './call-tool-result.js';
|
|
2
|
+
|
|
3
|
+
import type { TAppToolArguments, TAppToolOutcome, TAppView } from './types.js';
|
|
4
|
+
|
|
5
|
+
export const APP_VIEW_PROTOCOL_VERSION = '2026-01-26';
|
|
6
|
+
export const APP_VIEW_MIN_HEIGHT_PX = 48;
|
|
7
|
+
export const APP_VIEW_MAX_HEIGHT_PX = 640;
|
|
8
|
+
|
|
9
|
+
const INVALID_PARAMS = -32602;
|
|
10
|
+
const METHOD_NOT_FOUND = -32601;
|
|
11
|
+
const HOST_ERROR = -32000;
|
|
12
|
+
|
|
13
|
+
const SANDBOX_PROXY_READY = 'ui/notifications/sandbox-proxy-ready';
|
|
14
|
+
const SANDBOX_RESOURCE_READY = 'ui/notifications/sandbox-resource-ready';
|
|
15
|
+
|
|
16
|
+
type TJsonRpcId = string | number;
|
|
17
|
+
|
|
18
|
+
export interface IAppViewMessage {
|
|
19
|
+
jsonrpc: '2.0';
|
|
20
|
+
id?: TJsonRpcId;
|
|
21
|
+
method?: string;
|
|
22
|
+
params?: unknown;
|
|
23
|
+
result?: unknown;
|
|
24
|
+
error?: { code: number; message: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IAppViewHostContext {
|
|
28
|
+
theme: 'light' | 'dark';
|
|
29
|
+
displayMode: 'inline';
|
|
30
|
+
availableDisplayModes: readonly ['inline'];
|
|
31
|
+
containerDimensions: { maxHeight: number };
|
|
32
|
+
platform: 'desktop' | 'web';
|
|
33
|
+
locale: string;
|
|
34
|
+
timeZone: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface IAppViewBridgeOptions {
|
|
38
|
+
view: TAppView;
|
|
39
|
+
|
|
40
|
+
toolResult: ICallToolResult;
|
|
41
|
+
hostInfo: { name: string; version: string };
|
|
42
|
+
hostContext: IAppViewHostContext;
|
|
43
|
+
|
|
44
|
+
post: (message: IAppViewMessage) => void;
|
|
45
|
+
|
|
46
|
+
source: () => MessageEventSource | null;
|
|
47
|
+
|
|
48
|
+
callAppTool: (runtimeName: string, args: TAppToolArguments) => Promise<TAppToolOutcome>;
|
|
49
|
+
|
|
50
|
+
onHeight: (px: number) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface IAppViewBridge {
|
|
54
|
+
handleMessage: (event: MessageEvent) => void;
|
|
55
|
+
dispose: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
60
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
61
|
+
return prototype === null || Object.getPrototypeOf(prototype) === null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function errorMessage(error: unknown): string {
|
|
65
|
+
return error instanceof Error ? error.message : String(error);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createAppViewBridge(options: IAppViewBridgeOptions): IAppViewBridge {
|
|
69
|
+
const { view } = options;
|
|
70
|
+
let disposed = false;
|
|
71
|
+
let sandboxAnswered = false;
|
|
72
|
+
let initialization: 'uninitialized' | 'initializing' | 'ready' = 'uninitialized';
|
|
73
|
+
let callInFlight = false;
|
|
74
|
+
|
|
75
|
+
const respond = (id: TJsonRpcId, result: unknown): void => {
|
|
76
|
+
options.post({ jsonrpc: '2.0', id, result });
|
|
77
|
+
};
|
|
78
|
+
const refuse = (id: TJsonRpcId, code: number, message: string): void => {
|
|
79
|
+
options.post({ jsonrpc: '2.0', id, error: { code, message } });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const callTool = (id: TJsonRpcId, params: unknown): void => {
|
|
83
|
+
if (initialization !== 'ready') {
|
|
84
|
+
refuse(id, HOST_ERROR, 'This app view has not initialized.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const name = isPlainObject(params) ? params['name'] : undefined;
|
|
88
|
+
const runtimeName =
|
|
89
|
+
typeof name === 'string' && Object.hasOwn(view.callableTools, name)
|
|
90
|
+
? view.callableTools[name]
|
|
91
|
+
: undefined;
|
|
92
|
+
if (!isPlainObject(params) || runtimeName === undefined) {
|
|
93
|
+
refuse(id, INVALID_PARAMS, 'This tool is not available to this view.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const rawArguments = params['arguments'] === undefined ? {} : params['arguments'];
|
|
97
|
+
if (!isPlainObject(rawArguments)) {
|
|
98
|
+
refuse(id, INVALID_PARAMS, 'Tool arguments must be an object.');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (callInFlight) {
|
|
102
|
+
refuse(id, HOST_ERROR, 'Another tool call from this view is still running.');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
callInFlight = true;
|
|
106
|
+
let outcome: Promise<TAppToolOutcome>;
|
|
107
|
+
try {
|
|
108
|
+
outcome = options.callAppTool(runtimeName, rawArguments as TAppToolArguments);
|
|
109
|
+
} catch (error: unknown) {
|
|
110
|
+
outcome = Promise.reject(error);
|
|
111
|
+
}
|
|
112
|
+
outcome.then(
|
|
113
|
+
(settled) => {
|
|
114
|
+
callInFlight = false;
|
|
115
|
+
if (!disposed) respond(id, outcomeToCallToolResult(settled));
|
|
116
|
+
},
|
|
117
|
+
(error: unknown) => {
|
|
118
|
+
callInFlight = false;
|
|
119
|
+
if (!disposed) refuse(id, HOST_ERROR, errorMessage(error));
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const handleRequest = (id: TJsonRpcId, method: string, params: unknown): void => {
|
|
125
|
+
switch (method) {
|
|
126
|
+
case 'ui/initialize':
|
|
127
|
+
if (initialization === 'uninitialized') initialization = 'initializing';
|
|
128
|
+
respond(id, {
|
|
129
|
+
protocolVersion: APP_VIEW_PROTOCOL_VERSION,
|
|
130
|
+
hostCapabilities: { serverTools: {} },
|
|
131
|
+
hostInfo: options.hostInfo,
|
|
132
|
+
hostContext: options.hostContext,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
case 'tools/call':
|
|
136
|
+
callTool(id, params);
|
|
137
|
+
return;
|
|
138
|
+
case 'ping':
|
|
139
|
+
respond(id, {});
|
|
140
|
+
return;
|
|
141
|
+
case 'ui/request-display-mode':
|
|
142
|
+
respond(id, { mode: 'inline' });
|
|
143
|
+
return;
|
|
144
|
+
default:
|
|
145
|
+
refuse(id, METHOD_NOT_FOUND, `This host does not support ${method}.`);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleNotification = (method: string, params: unknown): void => {
|
|
150
|
+
switch (method) {
|
|
151
|
+
case SANDBOX_PROXY_READY:
|
|
152
|
+
// The sandbox page is the only sender; the document goes to it once, never again.
|
|
153
|
+
if (sandboxAnswered) return;
|
|
154
|
+
sandboxAnswered = true;
|
|
155
|
+
options.post({
|
|
156
|
+
jsonrpc: '2.0',
|
|
157
|
+
method: SANDBOX_RESOURCE_READY,
|
|
158
|
+
params: { html: view.html },
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
case 'ui/notifications/initialized':
|
|
162
|
+
if (initialization !== 'initializing') return;
|
|
163
|
+
initialization = 'ready';
|
|
164
|
+
options.post({
|
|
165
|
+
jsonrpc: '2.0',
|
|
166
|
+
method: 'ui/notifications/tool-input',
|
|
167
|
+
params: { arguments: view.toolInput ?? {} },
|
|
168
|
+
});
|
|
169
|
+
options.post({
|
|
170
|
+
jsonrpc: '2.0',
|
|
171
|
+
method: 'ui/notifications/tool-result',
|
|
172
|
+
params: options.toolResult,
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
case 'ui/notifications/size-changed': {
|
|
176
|
+
const height = isPlainObject(params) ? params['height'] : undefined;
|
|
177
|
+
if (typeof height !== 'number' || !Number.isFinite(height)) return;
|
|
178
|
+
options.onHeight(
|
|
179
|
+
Math.min(APP_VIEW_MAX_HEIGHT_PX, Math.max(APP_VIEW_MIN_HEIGHT_PX, Math.round(height))),
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
// Anything else a view announces (logging, its own notices) has no consumer here.
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
handleMessage(event) {
|
|
191
|
+
if (disposed) return;
|
|
192
|
+
const frame = options.source();
|
|
193
|
+
if (frame === null || event.source !== frame) return;
|
|
194
|
+
const data: unknown = event.data;
|
|
195
|
+
if (!isPlainObject(data) || data['jsonrpc'] !== '2.0') return;
|
|
196
|
+
const { method, id } = data;
|
|
197
|
+
if (typeof method !== 'string') return;
|
|
198
|
+
if (id === undefined) {
|
|
199
|
+
handleNotification(method, data['params']);
|
|
200
|
+
} else if (typeof id === 'string' || typeof id === 'number') {
|
|
201
|
+
handleRequest(id, method, data['params']);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
dispose() {
|
|
205
|
+
if (disposed) return;
|
|
206
|
+
disposed = true;
|
|
207
|
+
if (initialization === 'ready') {
|
|
208
|
+
options.post({
|
|
209
|
+
jsonrpc: '2.0',
|
|
210
|
+
id: 'teardown',
|
|
211
|
+
method: 'ui/resource-teardown',
|
|
212
|
+
params: { reason: 'closed' },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { TAppToolOutcome, TAppToolPart, TAppView } from './types.js';
|
|
2
|
+
import type { IActiveTool } from '../hooks/session-client-types.js';
|
|
3
|
+
|
|
4
|
+
export type TCallToolContent =
|
|
5
|
+
| { type: 'text'; text: string }
|
|
6
|
+
| { type: 'image'; data: string; mimeType: string }
|
|
7
|
+
| { type: 'audio'; data: string; mimeType: string };
|
|
8
|
+
|
|
9
|
+
export interface ICallToolResult {
|
|
10
|
+
content: TCallToolContent[];
|
|
11
|
+
structuredContent?: Record<string, unknown>;
|
|
12
|
+
isError: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function contentFromParts(parts: readonly TAppToolPart[] | undefined): TCallToolContent[] {
|
|
20
|
+
const content: TCallToolContent[] = [];
|
|
21
|
+
for (const part of parts ?? []) {
|
|
22
|
+
if (part.type === 'text') {
|
|
23
|
+
content.push({ type: 'text', text: part.text });
|
|
24
|
+
} else if (part.type === 'image_inline') {
|
|
25
|
+
content.push({ type: 'image', data: part.data, mimeType: part.mimeType });
|
|
26
|
+
} else if (part.type === 'audio_inline') {
|
|
27
|
+
content.push({ type: 'audio', data: part.data, mimeType: part.mimeType });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return content;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function outcomeToCallToolResult(outcome: TAppToolOutcome): ICallToolResult {
|
|
34
|
+
let content = contentFromParts(outcome.parts);
|
|
35
|
+
if (content.length === 0 && typeof outcome.data === 'string') {
|
|
36
|
+
content = [{ type: 'text', text: outcome.data }];
|
|
37
|
+
}
|
|
38
|
+
if (!outcome.success && outcome.error !== undefined) {
|
|
39
|
+
content = [...content, { type: 'text', text: outcome.error }];
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
content,
|
|
43
|
+
...(isRecord(outcome.data) ? { structuredContent: outcome.data } : {}),
|
|
44
|
+
isError: !outcome.success,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toCallToolResult(tool: IActiveTool, view: TAppView): ICallToolResult {
|
|
49
|
+
let content = contentFromParts(tool.toolResultParts);
|
|
50
|
+
if (content.length === 0 && tool.toolResultData !== undefined) {
|
|
51
|
+
content = [{ type: 'text', text: tool.toolResultData }];
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
content,
|
|
55
|
+
...(isRecord(view.structuredContent) ? { structuredContent: view.structuredContent } : {}),
|
|
56
|
+
isError: tool.status === 'error',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
import { APP_VIEW_MAX_HEIGHT_PX, type IAppViewHostContext } from './bridge.js';
|
|
4
|
+
|
|
5
|
+
import type { TAppToolArguments, TAppToolOutcome, TAppView } from './types.js';
|
|
6
|
+
|
|
7
|
+
export interface IAppViewHost {
|
|
8
|
+
sandboxUrl?: string;
|
|
9
|
+
|
|
10
|
+
readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
|
|
11
|
+
callAppTool: (
|
|
12
|
+
viewKey: string,
|
|
13
|
+
name: string,
|
|
14
|
+
args: TAppToolArguments,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
) => Promise<TAppToolOutcome>;
|
|
17
|
+
hostInfo: { name: string; version: string };
|
|
18
|
+
platform: 'desktop' | 'web';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const AppViewHostContext = createContext<IAppViewHost | null>(null);
|
|
22
|
+
|
|
23
|
+
export function readAppViewHostContext(platform: IAppViewHost['platform']): IAppViewHostContext {
|
|
24
|
+
const dark =
|
|
25
|
+
typeof window !== 'undefined' &&
|
|
26
|
+
typeof window.matchMedia === 'function' &&
|
|
27
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
28
|
+
return {
|
|
29
|
+
theme: dark ? 'dark' : 'light',
|
|
30
|
+
displayMode: 'inline',
|
|
31
|
+
availableDisplayModes: ['inline'],
|
|
32
|
+
containerDimensions: { maxHeight: APP_VIEW_MAX_HEIGHT_PX },
|
|
33
|
+
platform,
|
|
34
|
+
locale: typeof navigator === 'undefined' ? 'en-US' : navigator.language,
|
|
35
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TClientMessage, TServerMessage } from '@robota-sdk/agent-transport';
|
|
2
|
+
|
|
3
|
+
export type TAppView = Extract<TServerMessage, { type: 'app_view' }>['view'];
|
|
4
|
+
|
|
5
|
+
export type TAppToolOutcome = Omit<
|
|
6
|
+
Extract<TServerMessage, { type: 'app_tool_result' }>,
|
|
7
|
+
'type' | 'requestId'
|
|
8
|
+
>;
|
|
9
|
+
|
|
10
|
+
export type TAppToolArguments = Extract<TClientMessage, { type: 'call-app-tool' }>['arguments'];
|
|
11
|
+
|
|
12
|
+
export type TAppToolPart = NonNullable<TAppToolOutcome['parts']>[number];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { createAppViewBridge } from '../app-view/bridge.js';
|
|
6
|
+
import { toCallToolResult } from '../app-view/call-tool-result.js';
|
|
7
|
+
import { AppViewHostContext, readAppViewHostContext } from '../app-view/host-context.js';
|
|
8
|
+
import { appViewKey } from '../hooks/use-app-view-requests.js';
|
|
9
|
+
|
|
10
|
+
import type { IAppViewHost } from '../app-view/host-context.js';
|
|
11
|
+
import type { TAppView } from '../app-view/types.js';
|
|
12
|
+
import type { IActiveTool } from '../hooks/useSessionClient.js';
|
|
13
|
+
|
|
14
|
+
const INITIAL_HEIGHT_PX = 160;
|
|
15
|
+
|
|
16
|
+
export const APP_VIEW_UNAVAILABLE_TEXT =
|
|
17
|
+
'This tool returned an app view, which this window cannot show.';
|
|
18
|
+
|
|
19
|
+
type TLoad =
|
|
20
|
+
| { status: 'loading' }
|
|
21
|
+
| { status: 'ready'; view: TAppView; host: IAppViewHost; sandboxUrl: string; signal: AbortSignal }
|
|
22
|
+
| { status: 'failed'; reason: string };
|
|
23
|
+
|
|
24
|
+
export function AppView({ tool }: { tool: IActiveTool }): React.ReactElement | null {
|
|
25
|
+
const host = useContext(AppViewHostContext);
|
|
26
|
+
if (tool.appView === undefined) return null;
|
|
27
|
+
const viewKey = appViewKey(tool);
|
|
28
|
+
|
|
29
|
+
if (host === null || host.sandboxUrl === undefined || viewKey === undefined) {
|
|
30
|
+
return <p className="text-[13px] text-subtle">{APP_VIEW_UNAVAILABLE_TEXT}</p>;
|
|
31
|
+
}
|
|
32
|
+
return (
|
|
33
|
+
<AppViewFrame
|
|
34
|
+
key={viewKey}
|
|
35
|
+
tool={tool}
|
|
36
|
+
host={host}
|
|
37
|
+
sandboxUrl={host.sandboxUrl}
|
|
38
|
+
viewKey={viewKey}
|
|
39
|
+
sourceId={tool.appView.sourceId}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function AppViewFrame({
|
|
45
|
+
tool,
|
|
46
|
+
host,
|
|
47
|
+
sandboxUrl,
|
|
48
|
+
viewKey,
|
|
49
|
+
sourceId,
|
|
50
|
+
}: {
|
|
51
|
+
tool: IActiveTool;
|
|
52
|
+
host: IAppViewHost;
|
|
53
|
+
sandboxUrl: string;
|
|
54
|
+
|
|
55
|
+
viewKey: string;
|
|
56
|
+
sourceId: string;
|
|
57
|
+
}): React.ReactElement {
|
|
58
|
+
const [load, setLoad] = useState<TLoad>({ status: 'loading' });
|
|
59
|
+
const [height, setHeight] = useState(INITIAL_HEIGHT_PX);
|
|
60
|
+
const frameRef = useRef<HTMLIFrameElement>(null);
|
|
61
|
+
const toolRef = useRef(tool);
|
|
62
|
+
toolRef.current = tool;
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
setLoad({ status: 'loading' });
|
|
67
|
+
host.readAppView(viewKey, controller.signal).then(
|
|
68
|
+
(view) => {
|
|
69
|
+
if (!controller.signal.aborted)
|
|
70
|
+
setLoad({ status: 'ready', view, host, sandboxUrl, signal: controller.signal });
|
|
71
|
+
},
|
|
72
|
+
(error: unknown) => {
|
|
73
|
+
if (!controller.signal.aborted) {
|
|
74
|
+
setLoad({
|
|
75
|
+
status: 'failed',
|
|
76
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
return () => {
|
|
82
|
+
controller.abort();
|
|
83
|
+
};
|
|
84
|
+
}, [viewKey, host.readAppView, host.callAppTool, sandboxUrl]);
|
|
85
|
+
|
|
86
|
+
const ready =
|
|
87
|
+
load.status === 'ready' &&
|
|
88
|
+
load.host.readAppView === host.readAppView &&
|
|
89
|
+
load.host.callAppTool === host.callAppTool &&
|
|
90
|
+
load.sandboxUrl === sandboxUrl
|
|
91
|
+
? load
|
|
92
|
+
: null;
|
|
93
|
+
// A layout effect, so the teardown it posts on unmount still finds the frame in the document.
|
|
94
|
+
useLayoutEffect(() => {
|
|
95
|
+
const frame = frameRef.current;
|
|
96
|
+
if (ready === null || frame === null) return undefined;
|
|
97
|
+
const current = ready.host;
|
|
98
|
+
const view = ready.view;
|
|
99
|
+
const bridge = createAppViewBridge({
|
|
100
|
+
view,
|
|
101
|
+
toolResult: toCallToolResult(toolRef.current, view),
|
|
102
|
+
hostInfo: current.hostInfo,
|
|
103
|
+
hostContext: readAppViewHostContext(current.platform),
|
|
104
|
+
// The frame's origin is opaque, so no target origin can name it; the bridge accepts replies
|
|
105
|
+
|
|
106
|
+
post: (message) => frame.contentWindow?.postMessage(message, '*'),
|
|
107
|
+
source: () => frame.contentWindow,
|
|
108
|
+
|
|
109
|
+
callAppTool: (name, args) => current.callAppTool(viewKey, name, args, ready.signal),
|
|
110
|
+
onHeight: setHeight,
|
|
111
|
+
});
|
|
112
|
+
const onMessage = (event: MessageEvent): void => bridge.handleMessage(event);
|
|
113
|
+
window.addEventListener('message', onMessage);
|
|
114
|
+
return () => {
|
|
115
|
+
window.removeEventListener('message', onMessage);
|
|
116
|
+
bridge.dispose();
|
|
117
|
+
};
|
|
118
|
+
}, [ready, viewKey]);
|
|
119
|
+
|
|
120
|
+
if (load.status === 'failed') {
|
|
121
|
+
return (
|
|
122
|
+
<p className="text-[13px] text-subtle">
|
|
123
|
+
The app from {sourceId} could not be shown: {load.reason}
|
|
124
|
+
</p>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (ready === null) {
|
|
128
|
+
return <p className="text-[13px] text-subtle">Loading the app from {sourceId}…</p>;
|
|
129
|
+
}
|
|
130
|
+
return (
|
|
131
|
+
<div className="my-1 flex flex-col gap-1">
|
|
132
|
+
<p className="text-[12px] text-subtle">App from {sourceId}</p>
|
|
133
|
+
<iframe
|
|
134
|
+
ref={frameRef}
|
|
135
|
+
|
|
136
|
+
// `allow-same-origin` the frame has an opaque origin, so its document cannot read this page.
|
|
137
|
+
sandbox="allow-scripts"
|
|
138
|
+
src={sandboxUrl}
|
|
139
|
+
title={`App from ${sourceId}`}
|
|
140
|
+
referrerPolicy="no-referrer"
|
|
141
|
+
data-testid="app-view-frame"
|
|
142
|
+
style={{ width: '100%', height }}
|
|
143
|
+
className="block rounded-lg border border-border bg-card"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
|
|
22
22
|
import { useCopyFeedback } from '../clipboard.js';
|
|
23
23
|
import { driverAttributionText, isSameSurface } from '../driver-labels.js';
|
|
24
|
+
import { AppView } from './AppView.js';
|
|
24
25
|
import { DiffLines } from './DiffLines.js';
|
|
25
26
|
|
|
26
27
|
import type {
|
|
@@ -584,6 +585,15 @@ function ToolGroup({ tools }: { tools: readonly IActiveTool[] }): React.ReactEle
|
|
|
584
585
|
className={`flex-shrink-0 text-subtle transition-transform ${open ? 'rotate-90' : ''}`}
|
|
585
586
|
/>
|
|
586
587
|
</button>
|
|
588
|
+
{/* A call's app is the point of the call, so it shows without opening the group — and only
|
|
589
|
+
here: the rows below never repeat it. */}
|
|
590
|
+
{visible
|
|
591
|
+
.filter((tool) => tool.appView !== undefined)
|
|
592
|
+
.map((tool) => (
|
|
593
|
+
<div key={tool.id} className="pl-[25px]">
|
|
594
|
+
<AppView tool={tool} />
|
|
595
|
+
</div>
|
|
596
|
+
))}
|
|
587
597
|
{open && (
|
|
588
598
|
<div className="mt-0.5 flex flex-col pl-[25px]">
|
|
589
599
|
{visible.map((tool) => (
|
|
@@ -746,7 +756,14 @@ export function ConversationView({
|
|
|
746
756
|
{activeTools
|
|
747
757
|
.filter((tool) => !tool.internal)
|
|
748
758
|
.map((tool) => (
|
|
749
|
-
<
|
|
759
|
+
<React.Fragment key={tool.id}>
|
|
760
|
+
<ToolCard tool={tool} />
|
|
761
|
+
{tool.appView !== undefined && tool.status !== 'running' && (
|
|
762
|
+
<div className="pl-[25px]">
|
|
763
|
+
<AppView tool={tool} />
|
|
764
|
+
</div>
|
|
765
|
+
)}
|
|
766
|
+
</React.Fragment>
|
|
750
767
|
))}
|
|
751
768
|
</div>
|
|
752
769
|
)}
|