Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/src/components/agents/tool-cards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { GenericToolCard, GenericToolCardBody } from './generic-tool-card';
export { GlobToolCard, GlobToolCardBody } from './glob-tool-card';
export { GrepToolCard, GrepToolCardBody } from './grep-tool-card';
export { ListToolCard, ListToolCardBody } from './list-tool-card';
export { McpAppToolCard } from './mcp-app-card';
export { PatchToolCard, PatchToolCardBody } from './patch-tool-card';
export { ReadToolCard, ReadToolCardBody } from './read-tool-card';
export { TaskToolCard, TaskToolCardBody } from './task-tool-card';
Expand Down
253 changes: 253 additions & 0 deletions apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useColorScheme, View } from 'react-native';
import { WebView } from 'react-native-webview';

import { type ToolPart } from '@kilocode/cloud-agent-sdk';
import { type RemoteMcpApp } from '@kilocode/harness-sdk/plugins/remote-mcp';

import { ActivityIndicator } from '@/components/ui/activity-indicator';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { mcpCallResultOf } from '@/lib/chat/remote-mcp-call-results';
import { openRemoteMcpApp, readRemoteMcpView } from '@/lib/chat/remote-mcp';
import {
DEFAULT_VIEW_HEIGHT,
MAX_VIEW_HEIGHT,
VIEW_HOST_PAGE,
viewArguments,
viewHeightOf,
} from '@/lib/chat/remote-mcp-app';
import { type RemoteMcpToolView } from '@/lib/chat/remote-mcp-state';
import { deviceZone } from '@/lib/chat/tool-set';
import { openExternalUrl } from '@/lib/external-link';

import { GenericToolCard } from './generic-tool-card';

/**
* An MCP Apps view, drawn under the tool call it belongs to.
*
* The tool's row is the one every tool has; the view is the interface the
* server wrote for that call, in a WebView that loads one page of the app's
* own: a proxy that owns a single sandboxed iframe. The view's HTML comes from
* somebody else's server, so it runs with an opaque origin, under the policy
* the resource declared, with no file access and no way to navigate anything
* but itself. Everything that crosses into it or out of it is JSON text through
* the bridge (`remote-mcp-app.ts` holds the page and the policy;
* `openRemoteMcpApp` holds the protocol).
*
* The box keeps one height from the moment the row is drawn — the view's own
* reported height inside the clamp — so a view that loads a moment after its
* call does not move the transcript.
*/

/**
* The page's origin in the WebView. The page is loaded from the app's own
* memory (`source={{ html }}`), so it has no file or network origin of its own
* to reach anything with — and nothing but it may load.
*/
const HOST_PAGE_ORIGIN = 'about:blank';

/** What the row draws: the document, or why there is none. */
type ViewState =
| { readonly kind: 'loading' }
| { readonly kind: 'document'; readonly html: string }
| { readonly kind: 'empty' }
| { readonly kind: 'unavailable' };

/** The page's call that puts a document in the sandbox, as a JS literal. */
const mountCall = (html: string): string =>
`window.kiloMcpHost.mount(${JSON.stringify(html)});true;`;

/** The page's call that posts one bridge message into the sandbox. */
const deliverCall = (text: string): string =>
`window.kiloMcpHost.deliver(${JSON.stringify(text)});true;`;

type McpAppViewProps = {
readonly part: ToolPart;
readonly view: RemoteMcpToolView;
};

function McpAppView({ part, view }: Readonly<McpAppViewProps>) {
const { t, i18n } = useTranslation();
const dark = useColorScheme() === 'dark';
const [pageLoaded, setPageLoaded] = useState(false);
const [documentState, setDocumentState] = useState<ViewState>({ kind: 'loading' });
const [height, setHeight] = useState(DEFAULT_VIEW_HEIGHT);
const [attempt, setAttempt] = useState(0);

const webView = useRef<WebView<unknown> | null>(null);
const bridge = useRef<RemoteMcpApp | null>(null);

useEffect(() => {
let active = true;
const controller = new AbortController();
Comment thread
iscekic marked this conversation as resolved.
Resolved
setDocumentState({ kind: 'loading' });
const read = async () => {
const document = await readRemoteMcpView(view, controller.signal);
if (!active) {
return;
}
setDocumentState(
document.kind === 'ready' ? { kind: 'document', html: document.html } : document
);
};
void read();
return () => {
active = false;
// A row that scrolled away does not leave the request open.
controller.abort();
};
}, [attempt, view]);

// A retry mounts a page of its own, so the old page's loaded flag is not the
// new page's: the document read alone must not reset it, because a re-read for
// the same mounted page would then wait for a load that never comes again.
useEffect(() => {
if (attempt > 0) {
setPageLoaded(false);
}
}, [attempt]);

// A retry or a new document tears the old bridge down before the new one opens.
useEffect(
() => () => {
const held = bridge.current;
bridge.current = null;
void held?.close();
},
[attempt]
);

// The page is loaded and the document is read: connect, then let the view
// initialize. The order matters — a view that initializes before the bridge
// listens would never be answered.
useEffect(() => {
if (!pageLoaded || documentState.kind !== 'document' || bridge.current !== null) {
return undefined;
}
let active = true;
const html = documentState.html;
const open = async () => {
const app = openRemoteMcpApp(view, {
context: {
theme: dark ? 'dark' : 'light',
locale: i18n.language,
timeZone: deviceZone(),
maxHeight: MAX_VIEW_HEIGHT,
},
openLink: uri => void openExternalUrl(uri),
});
if (app === undefined) {
if (active) {
setDocumentState({ kind: 'unavailable' });
}
return;
}
bridge.current = app;
app.onSizeChanged(size => {
const reported = viewHeightOf(size.height);
setHeight(reported);
});
app.onInitialized(() => {
void app.input(viewArguments(part.state.status === 'completed' ? part.state.input : {}));
const answered = mcpCallResultOf(part.callID);
if (answered !== undefined) {
void app.result(answered);
}
});
try {
await app.connect({
send: text => {
webView.current?.injectJavaScript(deliverCall(text));
},
});
webView.current?.injectJavaScript(mountCall(html));
} catch {
// The handshake or the mount failed: the row says so and offers a retry.
if (active) {
setDocumentState({ kind: 'unavailable' });
}
}
};
void open();
return () => {
active = false;
};
}, [pageLoaded, documentState, dark, i18n.language, part, view]);

return (
<View style={{ height }} className="overflow-hidden rounded-lg bg-secondary">
<WebView<unknown>
// A retry gets a page of its own: a page that failed to load has no
// host object left to hand a document to.
key={attempt}
ref={webView}
source={{ html: VIEW_HOST_PAGE }}
// Only the page itself may load; every tap stays in the app.
originWhitelist={[HOST_PAGE_ORIGIN]}
onShouldStartLoadWithRequest={request =>
request.url === HOST_PAGE_ORIGIN || request.url === ''
}
// The view's own document is handed to the sandbox as `srcdoc`, so
// the page reads no files and the sandbox reaches no origin.
allowFileAccess={false}
javaScriptCanOpenWindowsAutomatically={false}
setSupportMultipleWindows={false}
mixedContentMode="never"
incognito
scrollEnabled={false}
accessibilityLabel={t('modelChat.mcp.app.label')}
onLoadEnd={() => {
setPageLoaded(true);
}}
onMessage={event => {
bridge.current?.receive(event.nativeEvent.data);
}}
onError={() => {
setDocumentState({ kind: 'unavailable' });
}}
/>
{documentState.kind === 'loading' ? (
<View className="absolute inset-0 items-center justify-center">
<ActivityIndicator />
</View>
) : null}
{documentState.kind === 'empty' ? (
<View className="absolute inset-0 items-center justify-center px-4">
<Text className="text-center text-xs text-muted-foreground">
{t('modelChat.mcp.app.empty')}
</Text>
</View>
) : null}
{documentState.kind === 'unavailable' ? (
<View className="absolute inset-0 items-center justify-center gap-2 px-4">
<Text className="text-center text-xs text-muted-foreground">
{t('modelChat.mcp.app.failed')}
</Text>
<Button
variant="secondary"
size="sm"
onPress={() => {
setAttempt(current => current + 1);
Comment thread
iscekic marked this conversation as resolved.
Resolved
}}
>
<Text>{t('common.retry')}</Text>
</Button>
</View>
) : null}
</View>
);
}

export function McpAppToolCard({
part,
view,
}: Readonly<{ part: ToolPart; view: RemoteMcpToolView }>) {
return (
<View className="gap-2">
<GenericToolCard part={part} />
<McpAppView part={part} view={view} />
</View>
);
}
34 changes: 34 additions & 0 deletions apps/mobile/src/components/agents/tool-part-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
GlobToolCard,
GrepToolCard,
ListToolCard,
McpAppToolCard,
PatchToolCard,
ReadToolCard,
TaskToolCard,
Expand All @@ -45,13 +46,20 @@ vi.mock('./message-error-boundary', () => ({ MessageErrorBoundary: 'MessageError
vi.mock('./suggest-tool-card', () => ({
SuggestToolCard: 'SuggestToolCard',
}));
vi.mock('@/lib/chat/remote-mcp-surface', () => ({
remoteServerView: (tool: string) =>
tool === 'mcp_alpha_chart'
? { serverId: 'alpha', serverToolName: 'chart', resourceUri: 'ui://alpha/chart.html' }
: undefined,
}));
vi.mock('./tool-cards', () => ({
BashToolCard: 'BashToolCard',
EditToolCard: 'EditToolCard',
GenericToolCard: 'GenericToolCard',
GlobToolCard: 'GlobToolCard',
GrepToolCard: 'GrepToolCard',
ListToolCard: 'ListToolCard',
McpAppToolCard: 'McpAppToolCard',
PatchToolCard: 'PatchToolCard',
ReadToolCard: 'ReadToolCard',
TaskToolCard: 'TaskToolCard',
Expand Down Expand Up @@ -381,6 +389,32 @@ describe('ToolPartRenderer routing', () => {
expect(findByType(root, GenericToolCard)).toHaveLength(1);
});

it('routes a completed remote tool that carries a view to the view card', () => {
// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const root = ToolPartRenderer({ part: makeToolPart('mcp_alpha_chart', completedState) });
expect(findByType(root, McpAppToolCard)).toHaveLength(1);
expect(findByType(root, GenericToolCard)).toHaveLength(0);
});

it('keeps the plain row for a remote tool whose server attached no view', () => {
// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const root = ToolPartRenderer({ part: makeToolPart('mcp_alpha_read', completedState) });
expect(findByType(root, McpAppToolCard)).toHaveLength(0);
expect(findByType(root, GenericToolCard)).toHaveLength(1);
});

it('keeps the plain row for a view’s call that has not finished', () => {
const running: ToolPart['state'] = {
status: 'running',
input: {},
time: { start: 1 },
};
// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const root = ToolPartRenderer({ part: makeToolPart('mcp_alpha_chart', running) });
expect(findByType(root, McpAppToolCard)).toHaveLength(0);
expect(findByType(root, GenericToolCard)).toHaveLength(1);
});

it.each(['plan_exit', 'plan_enter'])('returns null for %s parts', tool => {
// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const result = ToolPartRenderer({ part: makeToolPart(tool, completedState) });
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/components/agents/tool-part-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type StoredMessage, type ToolPart } from '@kilocode/cloud-agent-sdk';
import { type ReactNode } from 'react';

import { remoteServerView } from '@/lib/chat/remote-mcp-surface';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';

import {
Expand All @@ -15,6 +16,7 @@ import {
GlobToolCard,
GrepToolCard,
ListToolCard,
McpAppToolCard,
PatchToolCard,
ReadToolCard,
TaskToolCard,
Expand Down Expand Up @@ -113,7 +115,16 @@ export function ToolPartRenderer({
break;
}
default: {
card = <GenericToolCard part={part} />;
// A tool whose server attached an MCP Apps view draws that view under its
// row, once the call has an answer to hand it. Every other tool, and every
// call still running or failed, keeps the row it has always had.
const view = part.state.status === 'completed' ? remoteServerView(part.tool) : undefined;
card =
view === undefined ? (
<GenericToolCard part={part} />
) : (
<McpAppToolCard part={part} view={view} />
);
}
}

Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/i18n/locales/af.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion apps/mobile/src/i18n/locales/am.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions apps/mobile/src/i18n/locales/ar.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading