@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/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
@@ -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.94",
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.94",
62
- "@robota-sdk/agent-interface-session": "3.0.0-beta.94",
63
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.94",
64
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.94",
65
- "@robota-sdk/agent-transport": "3.0.0-beta.94"
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
  )}