From 9ad339ddf929e24533034a0a8d7215ef83275e4e Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 14:48:37 +0400
Subject: [PATCH 01/22] feat(claude): add desktop manifest and packaging
workflow
---
claude-desktop/manifest.json | 22 ++++++++++++++++++++++
package.json | 3 +++
scripts/claude-config.mjs | 19 +++++++++++++++++++
scripts/package-claude.mjs | 26 ++++++++++++++++++++++++++
4 files changed, 70 insertions(+)
create mode 100644 claude-desktop/manifest.json
create mode 100644 scripts/claude-config.mjs
create mode 100644 scripts/package-claude.mjs
diff --git a/claude-desktop/manifest.json b/claude-desktop/manifest.json
new file mode 100644
index 0000000..1e905de
--- /dev/null
+++ b/claude-desktop/manifest.json
@@ -0,0 +1,22 @@
+{
+ "manifest_version": "0.3",
+ "name": "mobile-dev",
+ "display_name": "Mobile Dev",
+ "version": "0.1.164",
+ "description": "Inspect local iOS and Android devices, logs and performance in Claude Desktop. Initial Apple Silicon development preview.",
+ "author": { "name": "Callstack" },
+ "server": {
+ "type": "node",
+ "entry_point": "dist/server.mjs",
+ "mcp_config": {
+ "command": "node",
+ "args": ["${__dirname}/dist/server.mjs"],
+ "env": { "MOBILE_DEV_HOST": "claude-desktop" }
+ }
+ },
+ "compatibility": {
+ "platforms": ["darwin"],
+ "runtimes": { "node": ">=22.19.0" }
+ },
+ "tools_generated": true
+}
diff --git a/package.json b/package.json
index 221c458..351c1db 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,9 @@
"test:ios-cpu": "node scripts/smoke-ios-cpu.mjs",
"package": "node scripts/package.mjs",
"package:release": "node scripts/package.mjs --release",
+ "claude:config": "node scripts/claude-config.mjs",
+ "package:claude": "node scripts/package-claude.mjs",
+ "test:claude": "node scripts/smoke-claude-package.mjs",
"vendor:serve-emu": "node scripts/vendor-serve-emu.mjs",
"dev": "node scripts/dev-ui.mjs",
"dev:off": "node scripts/dev-ui.mjs --off",
diff --git a/scripts/claude-config.mjs b/scripts/claude-config.mjs
new file mode 100644
index 0000000..128ee18
--- /dev/null
+++ b/scripts/claude-config.mjs
@@ -0,0 +1,19 @@
+import { access, mkdir, readFile, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+
+// Generate a snippet only; never replace the user's existing Claude configuration.
+await access("dist/server.mjs");
+const manifest = JSON.parse(await readFile("claude-desktop/manifest.json", "utf8"));
+const config = {
+ mcpServers: {
+ "mobile-dev": {
+ command: process.execPath,
+ args: [resolve(manifest.server.entry_point)],
+ env: manifest.server.mcp_config.env,
+ },
+ },
+};
+await mkdir(".local-dev", { recursive: true });
+const path = resolve(".local-dev/claude-desktop-config.json");
+await writeFile(path, JSON.stringify(config, null, 2) + "\n");
+console.log(`Merge the mcpServers entry from ${path} into Claude Desktop Settings > Developer > Edit Config, then fully restart Claude.`);
diff --git a/scripts/package-claude.mjs b/scripts/package-claude.mjs
new file mode 100644
index 0000000..e9b0651
--- /dev/null
+++ b/scripts/package-claude.mjs
@@ -0,0 +1,26 @@
+import { execFileSync } from "node:child_process";
+import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { telemetryBuildEnvironment } from "./telemetry-build.mjs";
+
+// Reuse the existing artifact inventory, native assets and environment checks.
+telemetryBuildEnvironment();
+execFileSync(process.execPath, ["scripts/package.mjs", ...process.argv.slice(2)], { stdio: "inherit" });
+const metadata = JSON.parse(await readFile("package.json", "utf8"));
+const manifest = JSON.parse(await readFile("claude-desktop/manifest.json", "utf8"));
+manifest.version = metadata.version;
+const source = resolve("release/marketplace/plugins/mobile-dev");
+const directory = resolve("release/claude-desktop/mobile-dev");
+await rm(directory, { recursive: true, force: true });
+await mkdir(directory, { recursive: true });
+for (const path of ["dist", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"]) {
+ await cp(`${source}/${path}`, `${directory}/${path}`, { recursive: true, verbatimSymlinks: true });
+}
+await cp("docs/claude-desktop.md", `${directory}/README.md`);
+await writeFile(`${directory}/manifest.json`, JSON.stringify(manifest, null, 2) + "\n");
+const archive = resolve(`release/mobile-dev-${manifest.version}-claude-desktop-darwin-arm64.mcpb`);
+await rm(archive, { force: true });
+// MCPB is a ZIP with manifest.json at its root. Preserve executable modes.
+execFileSync("zip", ["-qr", archive, "manifest.json", "dist", "README.md", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"], { cwd: directory });
+execFileSync("unzip", ["-tq", archive], { stdio: "inherit" });
+console.log(`Claude Desktop bundle: ${archive}`);
From c0058c8920ff3e8a6c6f182da71e495365f5c3ee Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 14:49:06 +0400
Subject: [PATCH 02/22] fix(claude): ask for ambiguous devices in chat
---
src/server/plugin.ts | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/src/server/plugin.ts b/src/server/plugin.ts
index d592bdc..3efd20a 100644
--- a/src/server/plugin.ts
+++ b/src/server/plugin.ts
@@ -90,22 +90,27 @@ function guarded(handler: (input: T, context: ServerRequestContext) => Promis
}
export async function createPlugin(html: string | (() => Promise), baguette = new Baguette(), simulatorInput: SimulatorInput = new SimulatorInputService(udid => baguette.repairInput(udid)), logs = new LogSessions(), android = new ServeEmu(), copyScreenshot = copyPNGToClipboard, providedCpu?: CpuSessions) {
+ const claudeDesktop = process.env.MOBILE_DEV_HOST === "claude-desktop";
const streams = new StreamSessions(baguette);
let selectedCpu = providedCpu;
if (selectedCpu === undefined) selectedCpu = await createCpuSessions();
const cpu = selectedCpu;
const fps = new DisplayFpsSessions();
const server = new McpServer({ name: "mobile-dev", version: PLUGIN_VERSION }, {
- instructions: "For mobile app development, open mobile_open_simulator beside the chat before the first device launch, or reuse the panel. Use selected device IDs. If a task has ambiguous device targets, discover candidates and ask with mobile_choose_devices; proceed only after action accept. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate. See the Mobile Dev skill for device control, logs, profiles, and performance workflows.",
+ instructions: claudeDesktop
+ ? "For mobile app development, call mobile_open_workspace to show Mobile Dev in the conversation. The user can expand it to fullscreen. Use selected device IDs. If device targets are ambiguous, discover candidates and ask the user in chat; wait for their answer before starting the task. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate."
+ : "For mobile app development, open mobile_open_simulator beside the chat before the first device launch, or reuse the panel. Use selected device IDs. If a task has ambiguous device targets, discover candidates and ask with mobile_choose_devices; proceed only after action accept. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate. See the Mobile Dev skill for device control, logs, profiles, and performance workflows.",
});
instrumentMcpServer(server);
const updates = new PluginUpdates();
addToolIcons(server, { mobile_open_workspace: PHONE_ICONS, mobile_open_simulator: PHONE_ICONS, mobile_choose_devices: PHONE_ICONS });
registerPluginUpdateTools(server, updates);
- const extensions = new OpenAIExtensions(server);
- registerDeviceChoiceTools(server, extensions, {
- simulators: () => baguette.start(), android: () => android.list(), physicalIos: listIosDevices,
- });
+ if (!claudeDesktop) {
+ const extensions = new OpenAIExtensions(server);
+ registerDeviceChoiceTools(server, extensions, {
+ simulators: () => baguette.start(), android: () => android.list(), physicalIos: listIosDevices,
+ });
+ }
registerIosDeviceTools(server);
const closeIosMirror = registerIosMirrorTools(server, APP_URI);
registerLogTools(server, logs, baguette);
From fb9f25979d690cff37853c0847f3dbc4fca89605 Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 14:49:26 +0400
Subject: [PATCH 03/22] fix(claude): disable codex updater and polling
---
src/server/plugin.ts | 3 ++-
src/ui/workspace-app.tsx | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/server/plugin.ts b/src/server/plugin.ts
index 3efd20a..125ea75 100644
--- a/src/server/plugin.ts
+++ b/src/server/plugin.ts
@@ -102,7 +102,7 @@ export async function createPlugin(html: string | (() => Promise), b
: "For mobile app development, open mobile_open_simulator beside the chat before the first device launch, or reuse the panel. Use selected device IDs. If a task has ambiguous device targets, discover candidates and ask with mobile_choose_devices; proceed only after action accept. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate. See the Mobile Dev skill for device control, logs, profiles, and performance workflows.",
});
instrumentMcpServer(server);
- const updates = new PluginUpdates();
+ const updates = new PluginUpdates(claudeDesktop ? { enabled: false } : {});
addToolIcons(server, { mobile_open_workspace: PHONE_ICONS, mobile_open_simulator: PHONE_ICONS, mobile_choose_devices: PHONE_ICONS });
registerPluginUpdateTools(server, updates);
if (!claudeDesktop) {
@@ -164,6 +164,7 @@ export async function createPlugin(html: string | (() => Promise), b
}
function configureUI(content: string): string {
+ if (claudeDesktop) content = content.replace(" {
performancePanel.setAvailable(available);
storagePanel.setAvailable(available);
deviceApps.setAvailable(available);
- updates.setAvailable(available);
+ updates.setAvailable(available && !claudeDesktop);
for (const panel of panels) if (!available) panel.notice("This host cannot call the plugin's simulator tools.");
if (available) {
stopLiveReload = startLiveReload(app, disposeUI);
From 45535fe12e562890425599abe08effe49002a3bf Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 14:49:27 +0400
Subject: [PATCH 04/22] feat(ui): support fullscreen in claude desktop
---
src/server/plugin.ts | 8 ++++++--
src/ui/components/display-mode-button.tsx | 20 ++++++++++++++++++++
src/ui/components/workspace.tsx | 4 +++-
src/ui/workspace-app.tsx | 9 ++++++---
4 files changed, 35 insertions(+), 6 deletions(-)
create mode 100644 src/ui/components/display-mode-button.tsx
diff --git a/src/server/plugin.ts b/src/server/plugin.ts
index 125ea75..ffdd54e 100644
--- a/src/server/plugin.ts
+++ b/src/server/plugin.ts
@@ -255,7 +255,9 @@ export async function createPlugin(html: string | (() => Promise), b
registerAppTool(server, "mobile_open_workspace", {
title: "Mobile Dev",
- description: "Open the fullscreen Mobile Dev workspace when the user asks for fullscreen, with logs and performance tools beside the devices. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. For app development beside a chat, prefer mobile_open_simulator. Starts the bundled backend without booting a device.",
+ description: claudeDesktop
+ ? "Open Mobile Dev in the conversation, with an Expand button for fullscreen devices, logs and performance tools. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting a device."
+ : "Open the fullscreen Mobile Dev workspace when the user asks for fullscreen, with logs and performance tools beside the devices. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. For app development beside a chat, prefer mobile_open_simulator. Starts the bundled backend without booting a device.",
inputSchema: panelTargetSchema, outputSchema: { ...statusOutput, panelTarget: panelTargetSchema }, annotations: write,
_meta: {
ui: { resourceUri: WORKSPACE_URI, visibility: ["app", "model"] },
@@ -265,7 +267,9 @@ export async function createPlugin(html: string | (() => Promise), b
registerAppTool(server, "mobile_open_simulator", {
title: "Mobile Dev",
- description: "Open Mobile Dev beside the chat for local mobile app development, before the first device launch unless a panel is open or the workflow is tool-only. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting devices.",
+ description: claudeDesktop
+ ? "Open a compact Mobile Dev view in the conversation. Prefer mobile_open_workspace for devices with logs and performance tools. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting devices."
+ : "Open Mobile Dev beside the chat for local mobile app development, before the first device launch unless a panel is open or the workflow is tool-only. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting devices.",
inputSchema: panelTargetSchema, outputSchema: { ...statusOutput, panelTarget: panelTargetSchema }, annotations: write,
_meta: {
ui: { resourceUri: APP_URI, visibility: ["app", "model"] },
diff --git a/src/ui/components/display-mode-button.tsx b/src/ui/components/display-mode-button.tsx
new file mode 100644
index 0000000..ff9b9ad
--- /dev/null
+++ b/src/ui/components/display-mode-button.tsx
@@ -0,0 +1,20 @@
+import type { App } from "@modelcontextprotocol/ext-apps";
+import { useState } from "react";
+import { Button } from "./ui/button";
+import { captureUiError, countUiEvent } from "../telemetry.ts";
+
+export function DisplayModeButton({ app }: { app: App }) {
+ const host = app.getHostContext();
+ const [busy, setBusy] = useState(false);
+ const mode = host?.displayMode === "fullscreen" ? "inline" : "fullscreen";
+ if (!host?.availableDisplayModes?.includes(mode)) return null;
+ return ;
+}
diff --git a/src/ui/components/workspace.tsx b/src/ui/components/workspace.tsx
index a5c6262..f74cca4 100644
--- a/src/ui/components/workspace.tsx
+++ b/src/ui/components/workspace.tsx
@@ -1,5 +1,6 @@
import { ActivityIcon, DatabaseIcon, TerminalIcon, PanelsTopLeftIcon } from "lucide-react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import type { ReactNode } from "react";
import type { LogsPanel } from "../logs-panel.ts";
import type { PerformancePanel } from "../performance-panel.ts";
import type { StoragePanel } from "../storage-panel.ts";
@@ -31,7 +32,7 @@ const tools = {
type Tool = keyof typeof tools;
const isTool = (value: string): value is Tool => value in tools;
-export function Workspace({ logs, performance, storage, recordingController, updates, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; recordingController: RecordingController; updates?: PluginUpdateController; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) {
+export function Workspace({ logs, performance, storage, recordingController, updates, displayModeControl, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; recordingController: RecordingController; updates?: PluginUpdateController; displayModeControl?: ReactNode; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) {
const [tool, setTool] = useState("logs");
const [savedVisible, setSavedVisible] = useState(false);
const [simulatorsCollapsed, setSimulatorsCollapsed] = useState(false);
@@ -106,6 +107,7 @@ export function Workspace({ logs, performance, storage, recordingController, upd
const menuSide = bottomToolbar ? "top" : "bottom";
const ToolIcon = tools[tool].icon;
const toolbar =
}>
-
+ : undefined} automaticLayout={automaticLayout} onLayout={changeLayout} />
;
reactRoot.render(workspace);
}
@@ -118,6 +119,8 @@ function hostContext() {
recordingController.hostChanged();
panelContext.resume();
for (const panel of panels) { panel.fitScreen(); panel.controls(); }
+ // Initial context arrives with connect(), without a hostcontextchanged event.
+ if (claudeDesktop) renderWorkspace();
}
function resumeContext() {
if (document.visibilityState !== "hidden" && !disposingUI) {
@@ -130,7 +133,7 @@ window.addEventListener("pageshow", resumeContext);
document.addEventListener("visibilitychange", resumeContext);
function connectionFailed(error: unknown) {
captureUiError(error, "host.connect");
- const message = error instanceof Error ? error.message : "Could not connect to Codex.";
+ const message = error instanceof Error ? error.message : "Could not connect to the host.";
for (const panel of panels) panel.notice(message);
}
function openDevices(input: unknown) {
From b41f98bd3ec61c500ba746acebf612c4b85955f0 Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 14:51:15 +0400
Subject: [PATCH 05/22] fix(ui): gate openai chat routing by host capability
---
src/ui/chat-message.ts | 11 +++++++++++
src/ui/comparison-controller.ts | 4 ++--
src/ui/model-context.ts | 13 +++++--------
src/ui/recording-controller.ts | 7 ++++---
src/ui/tracesift-controller.ts | 10 +++++-----
tests/chat-message.test.ts | 13 +++++++++++++
tests/log-context.test.ts | 6 +++---
tests/recording-context.test.ts | 2 +-
tests/screen-annotations.test.ts | 4 ++--
tests/tracesift-ui.test.ts | 6 +++---
10 files changed, 49 insertions(+), 27 deletions(-)
create mode 100644 src/ui/chat-message.ts
create mode 100644 tests/chat-message.test.ts
diff --git a/src/ui/chat-message.ts b/src/ui/chat-message.ts
new file mode 100644
index 0000000..e2c575a
--- /dev/null
+++ b/src/ui/chat-message.ts
@@ -0,0 +1,11 @@
+import type { App } from "@modelcontextprotocol/ext-apps";
+
+// Standard hosts receive a self-contained user message. Codex can additionally
+// route it to the active composer when it advertises that extension.
+export function chatMessage(app: Pick, text: string): Parameters[0] {
+ const routed = app.getHostCapabilities()?.experimental?.["openai/message"] != null;
+ return {
+ role: "user", content: [{ type: "text", text }],
+ ...(routed ? { _meta: { "openai/message": { target: "active", send: true } } } : {}),
+ };
+}
diff --git a/src/ui/comparison-controller.ts b/src/ui/comparison-controller.ts
index 9f86bd5..971c08c 100644
--- a/src/ui/comparison-controller.ts
+++ b/src/ui/comparison-controller.ts
@@ -1,6 +1,6 @@
+import { chatMessage } from "./chat-message.ts";
import type { App } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import type { OpenAIMessageParams } from "@openai/mcp-extensions/app";
import { comparisonDuration, comparisonSchema } from "../shared/performance-comparison.ts";
import type { PerformanceComparison } from "../shared/performance-comparison.ts";
import { recordingRangeSchema } from "../shared/recordings.ts";
@@ -100,7 +100,7 @@ export class ComparisonController {
const reference = JSON.stringify({ recordingIds, title: comparison.title, ...(range ? { range } : {}) });
const interval = range ? `${range.start}–${range.end}s` : "the entire runs";
const prompt = `Compare CPU, memory, and device-wide Display FPS for ${interval}, aligned at recording start. Read the original runs and summaries with mobile_compare_performance_recordings using ${reference}. Account for different durations, devices, apps and memory definitions. Distinguish measurements from hypotheses about their cause; device-wide display data cannot attribute a slowdown to one app.`;
- const message: OpenAIMessageParams = { role: "user", content: [{ type: "text", text: prompt }], _meta: { "openai/message": { target: "active", send: true } } };
+ const message = chatMessage(this.app, prompt);
const startedAt = performance.now();
try {
const result = await this.app.sendMessage(message);
diff --git a/src/ui/model-context.ts b/src/ui/model-context.ts
index e8a8b33..28a6c37 100644
--- a/src/ui/model-context.ts
+++ b/src/ui/model-context.ts
@@ -1,3 +1,4 @@
+import { chatMessage } from "./chat-message.ts";
import type { App } from "@modelcontextprotocol/ext-apps";
import type { OpenAIExtensions } from "@openai/mcp-extensions/app";
import type { SimulatorDevice } from "../shared/protocol.ts";
@@ -96,9 +97,7 @@ export class PanelContext {
if (this.canAttach && annotations.some(annotation => this.pendingAnnotations.has(annotation.id))) await this.publish();
const result = await withComposer(() => {
if (annotations.some(annotation => !this.annotations.some(item => item.id === annotation.id))) throw new Error("Annotations were removed from chat before sending.");
- return this.app.sendMessage({ role: "user", content: [
- { type: "text", text },
- ], _meta: { "openai/message": { target: "active", send: true } } }, { timeout: 5000, maxTotalTimeout: 5000 });
+ return this.app.sendMessage(chatMessage(this.app, text), { timeout: 5000, maxTotalTimeout: 5000 });
});
if (result.isError) throw new Error("Could not send these annotations to chat.");
const sent = new Set(annotations.map(item => item.id));
@@ -109,12 +108,12 @@ export class PanelContext {
} catch (error) {
if (composerUnavailable(error)) {
countUiEvent("ui.annotations.send_composer_unavailable");
- throw new Error("Codex could not find this chat's input. Try reopening the chat, or copy the notes and paste them into chat.");
+ throw new Error("The host could not find this chat's input. Try reopening the chat, or copy the notes and paste them into chat.");
}
// A timeout does not prove delivery failed. Never retry it automatically.
if (typeof error === "object" && error !== null && "code" in error && error.code === -32001) {
countUiEvent("ui.annotations.send_timeout");
- throw new Error("Codex did not confirm delivery. Check the chat before retrying to avoid sending the notes twice.");
+ throw new Error("The host did not confirm delivery. Check the chat before retrying to avoid sending the notes twice.");
}
countUiEvent("ui.annotations.send_failure");
captureUiError(new Error("Annotation chat send failed."), "annotations.send");
@@ -138,9 +137,7 @@ export class PanelContext {
const text = this.canAttach ? prompt : `${prompt}\n\n${formatLogContext(log)}`;
const result = await withComposer(() => {
if (this.canAttach && this.attached !== log) throw new Error("The selected log was removed or changed before sending.");
- return this.app.sendMessage({ role: "user", content: [{ type: "text", text }],
- _meta: { "openai/message": { target: "active", send: true } },
- }, { timeout: 5000, maxTotalTimeout: 5000 });
+ return this.app.sendMessage(chatMessage(this.app, text), { timeout: 5000, maxTotalTimeout: 5000 });
});
if (result.isError) throw new Error("Could not send this log to chat.");
if (this.attached === log) {
diff --git a/src/ui/recording-controller.ts b/src/ui/recording-controller.ts
index fca535e..5323692 100644
--- a/src/ui/recording-controller.ts
+++ b/src/ui/recording-controller.ts
@@ -1,6 +1,7 @@
+import { chatMessage } from "./chat-message.ts";
import type { App } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import type { OpenAIExtensions, OpenAIMessageParams } from "@openai/mcp-extensions/app";
+import type { OpenAIExtensions } from "@openai/mcp-extensions/app";
import { recordingRangeSchema, recordingSchema, summarizeRecording } from "../shared/recordings.ts";
import type { PerformanceRecording, RecordingRange } from "../shared/recordings.ts";
import { captureUiError, countUiEvent, markUiSurfaceReady, recordUiTiming, setUiGauge, setUiSurface, setUiTelemetryContext } from "./telemetry.ts";
@@ -148,7 +149,7 @@ export class RecordingController {
}
if (this.disposed) return;
const text = modelContext ? (action === "ask" ? (range ? "Explain this selected range." : "Explain this recording.") : "Open this recording in Mobile Dev.") : prompt;
- const message: OpenAIMessageParams = { role: "user", content: [{ type: "text", text }], _meta: { "openai/message": { target: "active", send: true } } };
+ const message = chatMessage(this.app, text);
const startedAt = performance.now();
sending = true;
const result = await this.app.sendMessage(message, { timeout: 5000, maxTotalTimeout: 5000 });
@@ -159,7 +160,7 @@ export class RecordingController {
} catch (error) {
captureUiError(new Error("Recording chat action failed."), `recording.${action}`);
const timedOut = sending && typeof error === "object" && error !== null && "code" in error && error.code === -32001;
- const message = timedOut ? "Codex did not confirm delivery. Check the chat before retrying to avoid sending the question twice." : error instanceof Error ? error.message : "Could not send this recording to chat.";
+ const message = timedOut ? "The host did not confirm delivery. Check the chat before retrying to avoid sending the question twice." : error instanceof Error ? error.message : "Could not send this recording to chat.";
this.update({ error: message });
} finally { this.update({ busy: false }); }
}
diff --git a/src/ui/tracesift-controller.ts b/src/ui/tracesift-controller.ts
index 205be73..8f670ee 100644
--- a/src/ui/tracesift-controller.ts
+++ b/src/ui/tracesift-controller.ts
@@ -1,6 +1,6 @@
+import { chatMessage } from "./chat-message.ts";
import type { App } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import type { OpenAIMessageParams } from "@openai/mcp-extensions/app";
import { traceSiftAnalysisSchema, traceSiftHandoffSchema, traceSiftRestoreSchema, type TraceSiftAnalysis, type TraceSiftProfileType } from "../shared/tracesift.ts";
import { decodeTraceSiftExplorePayload, TRACE_SIFT_PAYLOAD_MAX_BYTES, traceSiftExploreEnvelopeSchema, TRACE_SIFT_PAYLOAD_TOO_LARGE, traceSiftPayloadRefused, type TraceSiftExploreKind } from "../shared/tracesift-explore.ts";
import { buildSelectionPrompt, frameSelectionEvidence, type SelectionCard } from "../shared/tracesift-selection.ts";
@@ -199,11 +199,11 @@ export class TraceSiftController {
private async sendHandoff(text: string, event: string) {
let sending = false;
try {
- const message: OpenAIMessageParams = { role: "user", content: [{ type: "text", text }], _meta: { "openai/message": { target: "active", send: true } } };
+ const message = chatMessage(this.app, text);
const sendStartedAt = performance.now();
sending = true;
const sent = await this.app.sendMessage(message, { timeout: 5000, maxTotalTimeout: 5000 });
- if (sent.isError) throw new Error("Codex could not start this investigation.");
+ if (sent.isError) throw new Error("The host could not start this investigation.");
recordUiTiming("ui.tracesift.message_ack", performance.now() - sendStartedAt);
countUiEvent(event);
} catch (error) {
@@ -212,9 +212,9 @@ export class TraceSiftController {
countUiEvent(timedOut ? "ui.tracesift.delivery_unconfirmed" : missingComposer ? "ui.tracesift.composer_unavailable" : "ui.tracesift.send_failed");
if (!timedOut && !missingComposer) captureUiError(new Error("TraceSift investigation action failed."), "tracesift.investigate");
throw new Error(timedOut
- ? "Codex did not confirm delivery. Check the chat before retrying to avoid sending the investigation twice."
+ ? "The host did not confirm delivery. Check the chat before retrying to avoid sending the investigation twice."
: missingComposer
- ? "Codex could not find this chat's input. Reopen the chat, then try Investigate again."
+ ? "The host could not find this chat's input. Reopen the chat, then try Investigate again."
: error instanceof Error ? error.message : "Could not start this investigation.",
{ cause: error });
}
diff --git a/tests/chat-message.test.ts b/tests/chat-message.test.ts
new file mode 100644
index 0000000..d070a4d
--- /dev/null
+++ b/tests/chat-message.test.ts
@@ -0,0 +1,13 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { chatMessage } from "../src/ui/chat-message.ts";
+
+test("standard hosts receive a complete message without OpenAI routing", () => {
+ const message = chatMessage({ getHostCapabilities: () => ({ message: { text: {} } }) }, "Explain this log: failed request");
+ assert.deepEqual(message, { role: "user", content: [{ type: "text", text: "Explain this log: failed request" }] });
+});
+
+test("advertised OpenAI routing is preserved", () => {
+ const message = chatMessage({ getHostCapabilities: () => ({ experimental: { "openai/message": {} } }) }, "Explain this log");
+ assert.deepEqual(message._meta, { "openai/message": { target: "active", send: true } });
+});
diff --git a/tests/log-context.test.ts b/tests/log-context.test.ts
index 423e2b7..dd247e6 100644
--- a/tests/log-context.test.ts
+++ b/tests/log-context.test.ts
@@ -14,7 +14,7 @@ const log = stackLogs([{ timestamp: "2026-09-30T12:00:00Z", message: "Request fa
test("hosts without context pills receive the clicked log in the message", async () => {
const messages: Parameters[0][] = [];
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {} } }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {} } }),
async sendMessage(params: Parameters[0]) { messages.push(params); return {}; },
} as App, {} as OpenAIExtensions);
for (const level of ["error", "warn", "info", "debug"] as const) {
@@ -29,7 +29,7 @@ test("hosts without context pills receive the clicked log in the message", async
test("chat actions report host rejection and unsupported hosts", async () => {
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {} } }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {} } }),
async sendMessage() { return { isError: true }; },
} as unknown as App, {} as OpenAIExtensions);
await assert.rejects(context.sendLogToChat(log), /Could not send/);
@@ -53,7 +53,7 @@ function fixture(send: App["sendMessage"] = async () => ({})) {
};
const messages: Parameters[0][] = [];
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { image: {} } }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {} }, updateModelContext: { image: {} } }),
async sendMessage(...args: Parameters) { messages.push(args[0]); return send(...args); },
} as App, { modelContext } as OpenAIExtensions);
return { context, updates, messages, clear() { current = null; context.hostChanged(); }, removeContent() { if (current) current = { ...current, content: [] }; context.hostChanged(); }, removeScreenshot(id: string) { if (current) current = { ...current, updateId: "removed", content: current.content?.filter(item => item._meta?.["mobile-dev/screenshotId"] !== id) }; context.hostChanged(); }, fail() { failure = true; }, hold(value: Promise) { gate = value; } };
diff --git a/tests/recording-context.test.ts b/tests/recording-context.test.ts
index 123683b..7426717 100644
--- a/tests/recording-context.test.ts
+++ b/tests/recording-context.test.ts
@@ -28,7 +28,7 @@ function fixture(t: TestContext, pills = true) {
},
};
const app = {
- getHostCapabilities: () => ({ message: { text: {} } }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {} } }),
async sendMessage(params: Parameters[0], options: { timeout?: number; maxTotalTimeout?: number }) {
assert.equal(options.timeout, 5000);
assert.equal(options.maxTotalTimeout, 5000);
diff --git a/tests/screen-annotations.test.ts b/tests/screen-annotations.test.ts
index 0ae435f..370f1d2 100644
--- a/tests/screen-annotations.test.ts
+++ b/tests/screen-annotations.test.ts
@@ -28,7 +28,7 @@ function fixture(messageApp?: App) {
const updates: Parameters[0][] = [];
const messages: Parameters[0][] = [];
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {}, ...(imageMessages ? { image: {} } : {}) }, updateModelContext: imageAttachments ? { image: {} } : {} }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {}, ...(imageMessages ? { image: {} } : {}) }, updateModelContext: imageAttachments ? { image: {} } : {} }),
async sendMessage(message: Parameters[0], options: Parameters[1]) { if (sendFailures-- > 0) throw new Error("MCP app messages require an available composer"); messages.push(message); return messageApp ? messageApp.sendMessage(message, options) : { isError: rejected }; },
} as unknown as App, { modelContext: {
getCurrent: () => current,
@@ -514,7 +514,7 @@ test("a second batch survives a missing composer and sends after a manual retry"
f.delayMessageComposer(Infinity); await f.store.send();
assert.equal(f.store.getSnapshot().busy, false);
assert.equal(f.store.getSnapshot().sending, false);
- assert.match(f.store.getSnapshot().sendError, /Codex could not find this chat's input/);
+ assert.match(f.store.getSnapshot().sendError, /The host could not find this chat's input/);
assert.equal(f.context.screenAnnotations.length, 1);
assert.match(f.store.messageText, /SECOND_BATCH/);
assert.doesNotMatch(f.store.messageText, /Make this button larger|data:image|AA==/);
diff --git a/tests/tracesift-ui.test.ts b/tests/tracesift-ui.test.ts
index b07d579..6ca4b25 100644
--- a/tests/tracesift-ui.test.ts
+++ b/tests/tracesift-ui.test.ts
@@ -31,7 +31,7 @@ test("TraceSift Investigate fetches one authoritative handoff and sends it to th
assert.equal(calls.length, 1); assert.equal(messages.length, 1);
assert.deepEqual(calls[0], { name: "mobile_get_tracesift_handoff", arguments: { analysisId: "private-analysis", cardId: "react-commit-1-2" } });
assert.equal((messages[0] as { content: Array<{ text: string }> }).content[0].text, "PRIVATE_HANDOFF");
- assert.deepEqual((messages[0] as { _meta: unknown })._meta, { "openai/message": { target: "active", send: true } });
+ assert.equal((messages[0] as { _meta: unknown })._meta, undefined);
});
test("TraceSift disables investigation when host capabilities are absent", async () => {
@@ -544,7 +544,7 @@ test("TraceSift sends the selected frames' measured evidence to the active chat"
assert.match(text, /60 ms including everything it called/);
assert.match(text, /Search_Search rendered the list/);
assert.match(text, /← selection #1/);
- assert.deepEqual((messages[0] as { _meta: unknown })._meta, { "openai/message": { target: "active", send: true } });
+ assert.equal((messages[0] as { _meta: unknown })._meta, undefined);
// The picks never leave the frame: no handoff round trip, so an older
// installed TraceSift answers this question just as well.
assert.equal(controller.getSnapshot().error, "");
@@ -690,7 +690,7 @@ test("TraceSift sends the selected components' measured evidence to the active c
assert.match(text, /Location: `Screen` › `Chart`\./);
assert.match(text, /Treat each selection and render reason as a lead, not proof/);
assert.doesNotMatch(text, /Heavy component|Where those components sit/);
- assert.deepEqual((messages[0] as { _meta: unknown })._meta, { "openai/message": { target: "active", send: true } });
+ assert.equal((messages[0] as { _meta: unknown })._meta, undefined);
assert.equal(controller.getSnapshot().error, "");
controller.dispose();
});
From a3ce4050fa09ecd8da03b222a291945164597b2e Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 14:53:13 +0400
Subject: [PATCH 06/22] feat(ui): support standard mcp context attachments
---
src/ui/components/context-attachments.tsx | 20 ++++++++
src/ui/model-context.ts | 46 +++++++++++++++++--
src/ui/workspace-app.tsx | 3 +-
tests/log-context.test.ts | 56 +++++++++++++++++++++++
tests/screen-annotations.test.ts | 17 +++++++
5 files changed, 137 insertions(+), 5 deletions(-)
create mode 100644 src/ui/components/context-attachments.tsx
diff --git a/src/ui/components/context-attachments.tsx b/src/ui/components/context-attachments.tsx
new file mode 100644
index 0000000..5f883b3
--- /dev/null
+++ b/src/ui/components/context-attachments.tsx
@@ -0,0 +1,20 @@
+import { useState, useSyncExternalStore } from "react";
+import type { PanelContext } from "../model-context.ts";
+import { Button } from "./ui/button";
+import { captureUiError } from "../telemetry.ts";
+
+export function ContextAttachments({ context }: { context: PanelContext }) {
+ const count = useSyncExternalStore(context.subscribe, () => context.attachmentCount);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(false);
+ if (context.usesHostAttachments || !count) return null;
+ return
+
+ {error && Could not clear attachments. Try again.}
+
;
+}
diff --git a/src/ui/model-context.ts b/src/ui/model-context.ts
index 28a6c37..fe0af50 100644
--- a/src/ui/model-context.ts
+++ b/src/ui/model-context.ts
@@ -45,10 +45,13 @@ export class PanelContext {
private selectionKey?: string;
private readonly app: App;
private readonly extensions: OpenAIExtensions;
+ private contextReady = false;
onChange = () => {};
constructor(app: App, extensions: OpenAIExtensions) { this.app = app; this.extensions = extensions; }
- get canAttach() { return !!this.extensions.modelContext; }
+ get usesHostAttachments() { return !!this.extensions.modelContext; }
+ get canAttach() { return this.usesHostAttachments || !!this.app.getHostCapabilities()?.updateModelContext?.text; }
+ get attachmentCount() { return Number(!!this.attached) + this.screenshots.length + this.annotations.length; }
get canSendMessage() { return !!this.app.getHostCapabilities()?.message?.text; }
get canAttachScreenshots() { return this.canAttach && !!this.app.getHostCapabilities()?.updateModelContext?.image; }
get attachedKey() { return this.attached ? logKey(this.attached) : undefined; }
@@ -88,7 +91,7 @@ export class PanelContext {
const annotations = this.annotations.filter(item => item.simulator.udid === simulatorId);
if (!annotations.length) return;
const startedAt = performance.now();
- const text = this.canAttach ? ANNOTATION_EDIT_PROMPT : formatAnnotationMessage(annotations);
+ const text = this.usesHostAttachments ? ANNOTATION_EDIT_PROMPT : formatAnnotationMessage(annotations);
recordUiTiming("ui.annotations.message_build", performance.now() - startedAt);
if (annotations.some(annotation => !this.annotations.some(item => item.id === annotation.id))) throw new Error("Annotations were removed from chat before sending.");
const sendStartedAt = performance.now();
@@ -104,6 +107,7 @@ export class PanelContext {
this.annotations = this.annotations.filter(item => !sent.has(item.id));
for (const id of sent) this.pendingAnnotations.delete(id);
this.revision++; this.changed();
+ await this.clearSentContext();
countUiEvent("ui.annotations.send_success");
} catch (error) {
if (composerUnavailable(error)) {
@@ -134,7 +138,7 @@ export class PanelContext {
let publishing: Promise;
do { publishing = this.queue; await publishing; } while (publishing !== this.queue);
}
- const text = this.canAttach ? prompt : `${prompt}\n\n${formatLogContext(log)}`;
+ const text = this.usesHostAttachments ? prompt : `${prompt}\n\n${formatLogContext(log)}`;
const result = await withComposer(() => {
if (this.canAttach && this.attached !== log) throw new Error("The selected log was removed or changed before sending.");
return this.app.sendMessage(chatMessage(this.app, text), { timeout: 5000, maxTotalTimeout: 5000 });
@@ -143,6 +147,7 @@ export class PanelContext {
if (this.attached === log) {
this.attached = undefined;
this.revision++; this.changed();
+ await this.clearSentContext();
}
} finally { recordUiTiming("ui.logs.send", performance.now() - startedAt); }
}
@@ -181,10 +186,35 @@ export class PanelContext {
const revision = ++this.revision;
try { await this.publish(); }
catch (error) { if (this.revision === revision) this.screenshots = previous; throw error; }
+ finally { this.changed(); }
return this.screenshots.some(item => item.id === screenshot.id);
}
+ async clearAttachments() {
+ const previous = { attached: this.attached, screenshots: this.screenshots, annotations: this.annotations };
+ this.attached = undefined; this.screenshots = []; this.annotations = [];
+ const revision = ++this.revision;
+ try { await this.publish(); }
+ catch (error) {
+ if (revision === this.revision) Object.assign(this, previous);
+ throw error;
+ } finally { this.changed(); }
+ }
+
+ private async clearSentContext() {
+ if (this.usesHostAttachments) return;
+ // Delivery already succeeded. A context cleanup failure must not invite a
+ // duplicate send; retain the existing message timing and error boundaries.
+ await this.publish().catch(() => captureUiError(new Error("Could not clear sent context."), "context.clear_sent"));
+ }
+
hostChanged() {
+ if (!this.usesHostAttachments) {
+ const ready = this.canAttach;
+ if (ready && !this.contextReady) void this.publish().catch(() => {});
+ this.contextReady = ready;
+ return;
+ }
const current = this.extensions.modelContext?.getCurrent();
if (current === undefined) return;
const logPresent = this.attached && (current?.content
@@ -213,6 +243,7 @@ export class PanelContext {
}
private publish(): Promise {
+ if (!this.canAttach) return Promise.resolve();
const revision = this.revision;
const run = this.queue.catch(() => {}).then(async () => {
if (revision !== this.revision) return;
@@ -246,7 +277,14 @@ export class PanelContext {
await withComposer(async () => {
if (revision !== this.revision) return;
if (this.extensions.modelContext) this.updateId = (await this.extensions.modelContext.update(params, { timeout: 5000 }))?.updateId;
- else await this.app.updateModelContext(params, { timeout: 5000 });
+ else {
+ // Standard hosts own no composer pills. Local controls own selection
+ // and removal; publish the complete replacement context each time.
+ const content = params.content
+ .filter(item => item.type !== "image" || this.canAttachScreenshots)
+ .map(({ _meta, ...item }) => item);
+ await this.app.updateModelContext({ ...params, content }, { timeout: 5000 });
+ }
});
if (revision === this.revision) { this.deferred = false; this.pendingAnnotations.clear(); }
} catch (error) {
diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx
index f972f1d..173b938 100644
--- a/src/ui/workspace-app.tsx
+++ b/src/ui/workspace-app.tsx
@@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { Workspace } from "./components/workspace";
import { DisplayModeButton } from "./components/display-mode-button";
+import { ContextAttachments } from "./components/context-attachments";
import type { DeviceLayout } from "./device-layout.ts";
import { panelTargetSchema } from "../shared/panel-target.ts";
import { WorkspaceDevices } from "./workspace-devices.ts";
@@ -37,7 +38,7 @@ const updates = new PluginUpdateController(app);
const reactRoot = createRoot(document.getElementById("root")!);
function renderWorkspace(automaticLayout?: DeviceLayout) {
const workspace = Mobile Dev could not render. Reopen the panel to try again.}>
- : undefined} automaticLayout={automaticLayout} onLayout={changeLayout} />
+ {claudeDesktop && }>} automaticLayout={automaticLayout} onLayout={changeLayout} />
;
reactRoot.render(workspace);
}
diff --git a/tests/log-context.test.ts b/tests/log-context.test.ts
index dd247e6..b2641b3 100644
--- a/tests/log-context.test.ts
+++ b/tests/log-context.test.ts
@@ -338,3 +338,59 @@ test("side by side devices share both IDs while logs keep the active selection",
assert.equal(updates.at(-1)?.content?.some(item => item.type === "text" && item.text.includes("emulator-5554")), false);
assert.deepEqual(updates.at(-1)?.structuredContent?.selectedLog, log);
});
+
+test("standard context supports logs, local removal and complete chat messages", async () => {
+ const updates: Parameters[0][] = [];
+ const messages: Parameters[0][] = [];
+ const context = new PanelContext({
+ getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { text: {} } }),
+ async updateModelContext(params) { updates.push(params); return {}; },
+ async sendMessage(params) { messages.push(params); return {}; },
+ } as App, {} as OpenAIExtensions);
+ assert.equal(context.canAttach, true);
+ assert.equal(context.canAttachScreenshots, false);
+ await context.attach(log);
+ assert.equal(context.attachmentCount, 1);
+ assert.ok(updates.at(-1)?.content?.every(item => item._meta === undefined));
+ context.hostChanged();
+ assert.equal(context.attachmentCount, 1, "No host pills does not mean attachments were removed");
+ await context.sendLogToChat(log);
+ assert.match(JSON.stringify(messages[0].content), /Request failed/);
+ assert.equal(messages[0]._meta, undefined);
+ assert.equal(context.attachmentCount, 0);
+ assert.equal(updates.at(-1)?.structuredContent?.selectedLog, null);
+});
+
+test("standard screenshot context is capability-gated and clearing rolls back on failure", async () => {
+ let images = false;
+ let fail = false;
+ const updates: Parameters[0][] = [];
+ const context = new PanelContext({
+ getHostCapabilities: () => ({ updateModelContext: { text: {}, ...(images ? { image: {} } : {}) } }),
+ async updateModelContext(params) { if (fail) throw new Error("Host unavailable"); updates.push(params); return {}; },
+ } as unknown as App, {} as OpenAIExtensions);
+ const screenshot = { id: "shot-standard", simulator, data: "cG5n", mimeType: "image/png" } as ScreenshotAttachment;
+ await assert.rejects(context.attachScreenshot(screenshot), /does not support/);
+ images = true;
+ await context.attachScreenshot(screenshot);
+ assert.equal(context.attachmentCount, 1);
+ assert.equal(updates.at(-1)?.content?.filter(item => item.type === "image").length, 1);
+ fail = true;
+ await assert.rejects(context.clearAttachments(), /Host unavailable/);
+ assert.equal(context.attachmentCount, 1);
+ fail = false;
+ await context.clearAttachments();
+ assert.equal(context.attachmentCount, 0);
+ assert.equal(updates.at(-1)?.content?.some(item => item.type === "image"), false);
+});
+
+test("failed standard context cleanup never retries a delivered message", async () => {
+ let sent = 0;
+ const context = new PanelContext({
+ getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { text: {} } }),
+ async updateModelContext() { if (sent) throw new Error("Cleanup unavailable"); return {}; },
+ async sendMessage() { sent++; return {}; },
+ } as unknown as App, {} as OpenAIExtensions);
+ await context.sendLogToChat(log);
+ assert.equal(sent, 1);
+});
diff --git a/tests/screen-annotations.test.ts b/tests/screen-annotations.test.ts
index 370f1d2..0b541f0 100644
--- a/tests/screen-annotations.test.ts
+++ b/tests/screen-annotations.test.ts
@@ -570,3 +570,20 @@ test("a fresh annotation batch starts at one after chat clears or sends the prio
assert.equal(f.store.getSnapshot().draft?.text, "Keep this draft");
f.store.dispose();
});
+
+test("standard hosts receive complete annotation messages and cleared context after sending", async () => {
+ const messages: Parameters[0][] = [];
+ const updates: Parameters[0][] = [];
+ const context = new PanelContext({
+ getHostCapabilities: () => ({ updateModelContext: { text: {} }, message: { text: {} } }),
+ async updateModelContext(params) { updates.push(params); return {}; },
+ async sendMessage(params) { messages.push(params); return {}; },
+ } as App, {} as OpenAIExtensions);
+ await context.attachAnnotation(annotation);
+ assert.equal(context.screenAnnotations.length, 1);
+ await context.sendAnnotationsToChat(simulator.udid);
+ assert.match(JSON.stringify(messages[0].content), /Make this button larger/);
+ assert.equal(messages[0]._meta, undefined);
+ assert.equal(context.attachmentCount, 0);
+ assert.deepEqual(updates.at(-1)?.structuredContent?.annotationIds, []);
+});
From 92babc34f5fc2124e5a313eabf8eade4be3208d5 Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 15:06:00 +0400
Subject: [PATCH 07/22] fix(ui): use host theme tokens without openai styles
---
src/ui/app.tsx | 1 -
src/ui/theme.css | 31 ++++++++++++++++++++++++++++++-
src/ui/workspace-app.tsx | 2 --
3 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/src/ui/app.tsx b/src/ui/app.tsx
index 4e63783..607f8da 100644
--- a/src/ui/app.tsx
+++ b/src/ui/app.tsx
@@ -1,4 +1,3 @@
-import "@openai/mcp-extensions/app/styles.css";
import "@fontsource-variable/inter";
import "./theme.css";
import "./style.css";
diff --git a/src/ui/theme.css b/src/ui/theme.css
index 82e0135..ac450c3 100644
--- a/src/ui/theme.css
+++ b/src/ui/theme.css
@@ -7,9 +7,12 @@
@custom-variant dark (&:is([data-theme="dark"] *));
+@theme {
+ --font-sans: "Inter Variable", system-ui, sans-serif;
+}
+
@theme inline {
--font-heading: var(--font-sans);
- --font-sans: 'Inter Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -130,3 +133,29 @@ html[data-theme="dark"] {
@apply bg-background font-sans;
}
}
+
+/* Standard MCP host tokens override the shared light/dark fallbacks. */
+html[data-host="claude-desktop"] {
+ --foreground: var(--color-text-primary, light-dark(#1a1c1f, #fff));
+ --card: var(--color-background-secondary, light-dark(#fafafa, #262626));
+ --card-foreground: var(--foreground);
+ --popover: var(--color-background-primary, light-dark(#fff, #262626));
+ --popover-foreground: var(--foreground);
+ --muted: var(--color-background-secondary, light-dark(#f5f5f5, #404040));
+ --muted-foreground: var(--color-text-secondary, light-dark(#737373, #b4b4b4));
+ --secondary: var(--muted);
+ --secondary-foreground: var(--foreground);
+ --accent: var(--color-background-tertiary, var(--muted));
+ --accent-foreground: var(--foreground);
+ --border: var(--color-border-secondary, light-dark(#e5e5e5, #404040));
+ --input: var(--color-border-primary, var(--border));
+ --ring: var(--color-border-info, light-dark(#737373, #a3a3a3));
+ --radius: var(--border-radius-lg, 0.625rem);
+}
+
+@layer base {
+ body, body > * { font-family: var(--font-sans); }
+}
+@layer utilities {
+ .cursor-interaction { cursor: var(--cursor-interaction, pointer); }
+}
diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx
index 173b938..2d110ca 100644
--- a/src/ui/workspace-app.tsx
+++ b/src/ui/workspace-app.tsx
@@ -1,6 +1,5 @@
import { App, applyDocumentTheme, applyHostStyleVariables } from "@modelcontextprotocol/ext-apps";
import { OpenAIExtensions } from "@openai/mcp-extensions/app";
-import "@openai/mcp-extensions/app/styles.css";
import "@fontsource-variable/inter";
import "./theme.css";
import "./style.css";
@@ -115,7 +114,6 @@ function hostContext() {
const host = app.getHostContext();
if (host?.theme) applyDocumentTheme(host.theme);
if (host?.styles?.variables) applyHostStyleVariables(host.styles.variables);
- document.documentElement.style.setProperty("--font-sans", '"Inter Variable", sans-serif');
panelContext.hostChanged();
recordingController.hostChanged();
panelContext.resume();
From aa487ad65d9f29a2e59146da43f7d6559268bdb6 Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 15:08:08 +0400
Subject: [PATCH 08/22] fix(ui): respect structured context capability
---
src/ui/model-context.ts | 3 ++-
tests/log-context.test.ts | 5 +++--
tests/screen-annotations.test.ts | 2 +-
3 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/ui/model-context.ts b/src/ui/model-context.ts
index fe0af50..600cfb5 100644
--- a/src/ui/model-context.ts
+++ b/src/ui/model-context.ts
@@ -283,7 +283,8 @@ export class PanelContext {
const content = params.content
.filter(item => item.type !== "image" || this.canAttachScreenshots)
.map(({ _meta, ...item }) => item);
- await this.app.updateModelContext({ ...params, content }, { timeout: 5000 });
+ const structured = this.app.getHostCapabilities()?.updateModelContext?.structuredContent;
+ await this.app.updateModelContext({ content, ...(structured ? { structuredContent: params.structuredContent } : {}) }, { timeout: 5000 });
}
});
if (revision === this.revision) { this.deferred = false; this.pendingAnnotations.clear(); }
diff --git a/tests/log-context.test.ts b/tests/log-context.test.ts
index b2641b3..19e0dc8 100644
--- a/tests/log-context.test.ts
+++ b/tests/log-context.test.ts
@@ -343,7 +343,7 @@ test("standard context supports logs, local removal and complete chat messages",
const updates: Parameters[0][] = [];
const messages: Parameters[0][] = [];
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { text: {} } }),
+ getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { text: {}, structuredContent: {} } }),
async updateModelContext(params) { updates.push(params); return {}; },
async sendMessage(params) { messages.push(params); return {}; },
} as App, {} as OpenAIExtensions);
@@ -374,6 +374,7 @@ test("standard screenshot context is capability-gated and clearing rolls back on
images = true;
await context.attachScreenshot(screenshot);
assert.equal(context.attachmentCount, 1);
+ assert.equal(updates.at(-1)?.structuredContent, undefined, "Do not send unsupported structured context");
assert.equal(updates.at(-1)?.content?.filter(item => item.type === "image").length, 1);
fail = true;
await assert.rejects(context.clearAttachments(), /Host unavailable/);
@@ -387,7 +388,7 @@ test("standard screenshot context is capability-gated and clearing rolls back on
test("failed standard context cleanup never retries a delivered message", async () => {
let sent = 0;
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { text: {} } }),
+ getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { text: {}, structuredContent: {} } }),
async updateModelContext() { if (sent) throw new Error("Cleanup unavailable"); return {}; },
async sendMessage() { sent++; return {}; },
} as unknown as App, {} as OpenAIExtensions);
diff --git a/tests/screen-annotations.test.ts b/tests/screen-annotations.test.ts
index 0b541f0..613a5e9 100644
--- a/tests/screen-annotations.test.ts
+++ b/tests/screen-annotations.test.ts
@@ -575,7 +575,7 @@ test("standard hosts receive complete annotation messages and cleared context af
const messages: Parameters[0][] = [];
const updates: Parameters[0][] = [];
const context = new PanelContext({
- getHostCapabilities: () => ({ updateModelContext: { text: {} }, message: { text: {} } }),
+ getHostCapabilities: () => ({ updateModelContext: { text: {}, structuredContent: {} }, message: { text: {} } }),
async updateModelContext(params) { updates.push(params); return {}; },
async sendMessage(params) { messages.push(params); return {}; },
} as App, {} as OpenAIExtensions);
From 9f142b82d73fce1746ee7de56a12e7444277edfe Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 15:08:08 +0400
Subject: [PATCH 09/22] fix(ui): report usable inline workspace height
---
src/ui/components/context-attachments.tsx | 2 +-
src/ui/components/workspace.tsx | 5 +++--
src/ui/workspace-app.tsx | 21 +++++++++++++++++++--
3 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/src/ui/components/context-attachments.tsx b/src/ui/components/context-attachments.tsx
index 5f883b3..d848e8d 100644
--- a/src/ui/components/context-attachments.tsx
+++ b/src/ui/components/context-attachments.tsx
@@ -8,7 +8,7 @@ export function ContextAttachments({ context }: { context: PanelContext }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
if (context.usesHostAttachments || !count) return null;
- return
+ return