diff --git a/docs/cursor.md b/docs/cursor.md new file mode 100644 index 00000000..121d210d --- /dev/null +++ b/docs/cursor.md @@ -0,0 +1,65 @@ +# Cursor development preview + +Runs the shared Mobile Dev MCP server and native helpers on an Apple Silicon +Mac. Requires Node >=22.19, Xcode for iOS and the Android SDK for Android. +Cursor 2.6 introduced MCP Apps; use a current desktop version with Apps enabled. +This preview has automated protocol/package coverage. Actual Cursor rendering, +stream throughput and device interaction still require the checks below. + +## Run from source + +Complete the normal repository setup, then run: + +```sh +npm run build +npm run cursor:config +``` + +Merge the generated `.local-dev/cursor-mcp.json` server entry into +`~/.cursor/mcp.json` or your app project's `.cursor/mcp.json`. Preserve other +servers. The generator records absolute Node and server paths, so it does not +depend on Cursor's working directory or shell PATH. Restart the server in Cursor +after rebuilding. Regenerate configuration after moving the checkout or Node. + +Ask: **Open Mobile Dev and list available devices without booting anything.** +Choose the target in chat or in the device picker. Expand is available only when +the host advertises fullscreen. Codex update checks and installation are disabled. + +## Relocatable bundle + +```sh +npm run package:cursor +npm run test:cursor +``` + +Unzip `release/mobile-dev--cursor-darwin-arm64.zip` into a persistent +directory. Run `node configure.mjs` there and merge the printed configuration into +Cursor. It uses the Node executable running that command; no Codex installation +is required. Re-run configuration if the directory or Node moves. Replace the +bundle and restart its server to update. + +The bundle also contains a Cursor plugin manifest and `mcp.json` using +`${CURSOR_PLUGIN_ROOT}`. That plugin route requires Node on Cursor's PATH. +Do not enable both installation routes. Marketplace submission is separate from +creating this archive; no marketplace publication is implied. + +Local builds retain development telemetry. Public packaging requires +`npm run build:release` followed by `npm run package:cursor -- --release`, plus +the normal source-map and native-symbol upload workflow. + +## Validation before declaring support + +- Open an existing iOS simulator, stream for five minutes and verify tap/drag. +- Attach a log, screenshot and annotation; verify exactly what reaches chat. +- Record CPU/memory and ask about a selected range, checking the reference. +- Hide/reopen the card, switch chats and disconnect/reconnect the device. +- Test Android H.264 and physical-iOS HEVC separately from simulator JPEG. +- Check local-server policy permits required native helpers, ADB and local + sockets. The server must run on the Mac with the devices, not a cloud agent. + +The smoke test extracts into a path with spaces and verifies startup, UI metadata, +configuration relocation, telemetry opt-out and disabled Codex updates without +booting devices. Existing streaming, input and context telemetry is preserved. + +Sources: [Cursor MCP](https://cursor.com/docs/mcp), +[Cursor plugin reference](https://cursor.com/docs/reference/plugins). diff --git a/docs/telemetry.md b/docs/telemetry.md index 6cbdbc39..56265596 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -2,6 +2,16 @@ [Back to README](../README.md) · [Contributing](../CONTRIBUTING.md) +## Standard MCP Apps hosts + +Explicit host profiles preserve the existing UI/server/native initialization, +release environment, identity, sampling and scrubbing. Standard context and chat +handoffs retain their existing timing and handled-error boundaries. Workspace +display-mode changes use `ui.workspace.display_mode_changed`; resize failures use +`workspace.resize`. Surface/device attribution and hidden-panel cleanup remain +on the active rendering and streaming paths. The Codex updater and its polling +are disabled outside Codex. No native telemetry changes are required. + ## Volume policy Since 0.1.164, application metrics in the UI, Node servers and native helpers use diff --git a/integrations/cursor/.cursor-plugin/plugin.json b/integrations/cursor/.cursor-plugin/plugin.json new file mode 100644 index 00000000..ba5acd51 --- /dev/null +++ b/integrations/cursor/.cursor-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "mobile-dev", + "version": "0.1.164", + "description": "Interactive local iOS and Android devices, logs and performance tools in Cursor.", + "author": { "name": "Callstack", "email": "hello@callstack.com" }, + "homepage": "https://github.com/callstackincubator/codex-mobile-dev-plugin", + "mcpServers": "./mcp.json" +} diff --git a/integrations/cursor/mcp.json b/integrations/cursor/mcp.json new file mode 100644 index 00000000..7cd65905 --- /dev/null +++ b/integrations/cursor/mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "mobile-dev": { + "type": "stdio", + "command": "node", + "args": ["${CURSOR_PLUGIN_ROOT}/dist/server.mjs"], + "env": { "MOBILE_DEV_HOST": "cursor" } + } + } +} diff --git a/package.json b/package.json index 221c458f..ad564585 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", + "cursor:config": "node scripts/cursor-config.mjs", + "package:cursor": "node scripts/package-cursor.mjs", + "test:cursor": "node scripts/smoke-editor-package.mjs cursor", "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/cursor-config.mjs b/scripts/cursor-config.mjs new file mode 100644 index 00000000..365b87a3 --- /dev/null +++ b/scripts/cursor-config.mjs @@ -0,0 +1,9 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { localHostConfig } from "./local-host-config.mjs"; + +const config = await localHostConfig("cursor"); +await mkdir(".local-dev", { recursive: true }); +const path = resolve(".local-dev/cursor-mcp.json"); +await writeFile(path, JSON.stringify(config, null, 2) + "\n"); +console.log(`Merge the mobile-dev entry from ${path} into ~/.cursor/mcp.json or your project's .cursor/mcp.json, preserving existing servers.`); diff --git a/scripts/local-host-config.mjs b/scripts/local-host-config.mjs new file mode 100644 index 00000000..9526910c --- /dev/null +++ b/scripts/local-host-config.mjs @@ -0,0 +1,12 @@ +import { access } from "node:fs/promises"; +import { resolve } from "node:path"; + +// Generate data only. The caller decides where to save it; user settings are +// never overwritten. Resolve paths now so GUI hosts need no shell PATH or cwd. +export async function localHostConfig(host, root = ".", node = process.execPath) { + if (host !== "cursor" && host !== "vscode") throw new Error("Unsupported editor host."); + const server = resolve(root, "dist/server.mjs"); + await access(server); + const entry = { type: "stdio", command: node, args: [server], env: { MOBILE_DEV_HOST: host } }; + return host === "vscode" ? { servers: { "mobile-dev": entry } } : { mcpServers: { "mobile-dev": entry } }; +} diff --git a/scripts/package-cursor.mjs b/scripts/package-cursor.mjs new file mode 100644 index 00000000..c47a09d5 --- /dev/null +++ b/scripts/package-cursor.mjs @@ -0,0 +1,2 @@ +import { packageEditor } from "./package-editor.mjs"; +await packageEditor("cursor"); diff --git a/scripts/package-editor.mjs b/scripts/package-editor.mjs new file mode 100644 index 00000000..a4d1c2d5 --- /dev/null +++ b/scripts/package-editor.mjs @@ -0,0 +1,34 @@ +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"; + +export async function packageEditor(host) { + if (host !== "cursor" && host !== "vscode") throw new Error("Unsupported editor host."); + telemetryBuildEnvironment(); + // Retain the canonical native inventory, licenses and environment checks. + execFileSync(process.execPath, ["scripts/package.mjs", ...process.argv.slice(2)], { stdio: "inherit" }); + const { version } = JSON.parse(await readFile("package.json", "utf8")); + const source = resolve("release/marketplace/plugins/mobile-dev"); + const directory = resolve(`release/${host}/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(`integrations/${host}`, directory, { recursive: true }); + await cp(`docs/${host}.md`, `${directory}/README.md`); + await cp("scripts/local-host-config.mjs", `${directory}/local-host-config.mjs`); + await writeFile(`${directory}/configure.mjs`, `import { localHostConfig } from "./local-host-config.mjs";\nconsole.log(JSON.stringify(await localHostConfig(${JSON.stringify(host)}, import.meta.dirname), null, 2));\n`); + if (host === "cursor") { + const path = `${directory}/.cursor-plugin/plugin.json`; + const manifest = JSON.parse(await readFile(path, "utf8")); + manifest.version = version; + await writeFile(path, JSON.stringify(manifest, null, 2) + "\n"); + } + const archive = resolve(`release/mobile-dev-${version}-${host}-darwin-arm64.zip`); + await rm(archive, { force: true }); + execFileSync("zip", ["-qr", archive, "."], { cwd: directory }); + execFileSync("unzip", ["-tq", archive], { stdio: "inherit" }); + console.log(`${host} bundle: ${archive}`); +} diff --git a/scripts/smoke-editor-package.mjs b/scripts/smoke-editor-package.mjs new file mode 100644 index 00000000..22f49228 --- /dev/null +++ b/scripts/smoke-editor-package.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { access, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const host = process.argv[2]; +if (host !== "cursor" && host !== "vscode") throw new Error("Pass cursor or vscode."); +const { version } = JSON.parse(await readFile("package.json", "utf8")); +const archive = resolve(process.argv[3] ?? `release/mobile-dev-${version}-${host}-darwin-arm64.zip`); +const temporary = await realpath(await mkdtemp(join(tmpdir(), `mobile-dev-${host}-`))); +try { + const directory = join(temporary, "bundle with spaces"); + await mkdir(directory); + execFileSync("unzip", ["-q", archive, "-d", directory]); + const config = JSON.parse(execFileSync(process.execPath, [join(directory, "configure.mjs")], { cwd: temporary, encoding: "utf8" })); + const entry = config[host === "cursor" ? "mcpServers" : "servers"]["mobile-dev"]; + assert.equal(entry.command, process.execPath); + assert.equal(entry.env.MOBILE_DEV_HOST, host); + assert.equal(entry.args[0], join(directory, "dist/server.mjs")); + if (host === "cursor") { + const manifest = JSON.parse(await readFile(join(directory, ".cursor-plugin/plugin.json"), "utf8")); + assert.equal(manifest.version, version); + const pluginConfig = JSON.parse(await readFile(join(directory, "mcp.json"), "utf8")); + assert.equal(pluginConfig.mcpServers["mobile-dev"].env.MOBILE_DEV_HOST, host); + assert.equal(pluginConfig.mcpServers["mobile-dev"].args[0].replaceAll("${CURSOR_PLUGIN_ROOT}", directory), entry.args[0]); + } + for (const path of ["dist/app.html", "dist/baguette/Baguette", "dist/ios-mirror/darwin-arm64.node", "dist/serve-emu/src/cli.mjs"]) await access(join(directory, path)); + const home = join(temporary, "home"); + await mkdir(home); + for (const environment of ["development", "release"]) { + const client = new Client({ name: `${host}-package-smoke`, version: "1" }); + const transport = new StdioClientTransport({ + command: entry.command, args: entry.args, cwd: temporary, stderr: "pipe", + env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: home, ...entry.env, MOBILE_DEV_TELEMETRY: "off", MOBILE_DEV_ENVIRONMENT: environment }, + }); + let diagnostics = ""; + transport.stderr?.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-4000); }); + try { + await client.connect(transport); + const { tools } = await client.listTools(); + assert.ok(!tools.some(tool => tool.name === "mobile_choose_devices")); + const workspace = tools.find(tool => tool.name === "mobile_open_workspace"); + assert.ok(workspace); + const resource = await client.readResource({ uri: workspace._meta.ui.resourceUri }); + const html = resource.contents[0].text; + assert.ok(html.includes(`data-host="${host}"`)); + assert.ok(html.includes(`name="mobile-dev-environment" content="${environment}"`)); + assert.ok(html.includes('name="mobile-dev-telemetry" content="off"')); + const check = await client.callTool({ name: "mobile_check_plugin_update", arguments: {} }); + assert.equal(check.structuredContent.update.status, "disabled"); + const install = await client.callTool({ name: "mobile_install_plugin_update", arguments: {} }); + assert.equal(install.isError, true); + console.log(`${host}/${environment}: relocated startup, UI resource, telemetry opt-out and updater isolation passed.`); + } catch (error) { if (diagnostics) console.error(diagnostics); throw error; } + finally { await client.close(); await transport.close(); } + } +} finally { await rm(temporary, { recursive: true, force: true }); } diff --git a/src/server/plugin.ts b/src/server/plugin.ts index d592bdc8..d1a36244 100644 --- a/src/server/plugin.ts +++ b/src/server/plugin.ts @@ -1,3 +1,4 @@ +import { configuredHost } from "../shared/host.ts"; import { parseResourceInput } from "./resource-input.ts"; import { openRequestSession } from "./request-session.ts"; import type { ServerRequestContext } from "./request-session.ts"; @@ -90,22 +91,28 @@ 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 host = configuredHost(process.env.MOBILE_DEV_HOST); + const standardHost = host !== "codex"; 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: standardHost + ? "For mobile app development, call mobile_open_workspace to show Mobile Dev in the conversation. Use the display modes supported by the host. 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(); + const updates = new PluginUpdates(standardHost ? { enabled: false } : {}); 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 (!standardHost) { + 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); @@ -159,6 +166,7 @@ export async function createPlugin(html: string | (() => Promise), b } function configureUI(content: string): string { + if (standardHost) content = content.replace(" Promise), b ? content.replace('data-view="panel"', 'data-view="workspace"').replace('data-layout="stacked"', 'data-layout="split"') : content, _meta: { - ui: { csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }, + ui: { ...(standardHost ? { permissions: { clipboardWrite: {} } } : {}), csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }, "openai/ui": { preferredDisplayMode: "fullscreen", availableDisplayModes: ["inline", "fullscreen"] }, }, }], @@ -206,7 +214,7 @@ export async function createPlugin(html: string | (() => Promise), b const configured = configureUI(resource.html); const content = configured.replace('data-view="panel"', `data-view="${view}"`); return { contents: [{ uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text: content, _meta: { - ui: { prefersBorder: true, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }, + ui: { prefersBorder: true, ...(standardHost ? { permissions: { clipboardWrite: {} } } : {}), csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }, "openai/ui": { preferredDisplayMode: "inline", availableDisplayModes: ["inline", "fullscreen"] }, } }] }; }; @@ -249,7 +257,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: standardHost + ? "Open Mobile Dev in the conversation, with devices, logs and performance tools. Expand when supported by the host. 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"] }, @@ -259,7 +269,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: standardHost + ? "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/shared/host.ts b/src/shared/host.ts new file mode 100644 index 00000000..0cfc525c --- /dev/null +++ b/src/shared/host.ts @@ -0,0 +1,6 @@ +// Only explicitly configured distributions opt out of Codex behavior. +export function configuredHost(value: string | undefined): "codex" | "claude-desktop" | "cursor" | "vscode" { + if (value === undefined || value === "codex") return "codex"; + if (value === "claude-desktop" || value === "cursor" || value === "vscode") return value; + throw new Error("Unsupported Mobile Dev host."); +} diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 4e637831..607f8da9 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/chat-message.ts b/src/ui/chat-message.ts new file mode 100644 index 00000000..e2c575ad --- /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 9f86bd5d..971c08c1 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/components/context-attachments.tsx b/src/ui/components/context-attachments.tsx new file mode 100644 index 00000000..d848e8d1 --- /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/components/display-mode-button.tsx b/src/ui/components/display-mode-button.tsx new file mode 100644 index 00000000..ff9b9ad8 --- /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 a5c6262d..e361ffdb 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, contextControl, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; recordingController: RecordingController; updates?: PluginUpdateController; displayModeControl?: ReactNode; contextControl?: 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 =