@robota-sdk/agent-ui-web 3.0.0-beta.95 → 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/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; unsupported media is explained rather than
38
- silently discarded. Output is never recomputed here or buried behind a truncated path or argument; an internal signal tool never renders as a call,
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-ui-web",
3
- "version": "3.0.0-beta.95",
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.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"
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
- <ToolCard key={tool.id} tool={tool} />
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
  )}
@@ -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,10 +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
- // 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
+
66
+ app_view: 'reducer-state',
67
+ app_tool_result: 'reducer-state',
69
68
  background_task_event: 'intentionally-not-rendered',
70
69
  background_job_group_event: 'intentionally-not-rendered',
71
70
  plan_event: 'intentionally-not-rendered',