diff --git a/apps/claude/skills/mobile-dev/SKILL.md b/apps/claude/skills/mobile-dev/SKILL.md index aca25489..a2141328 100644 --- a/apps/claude/skills/mobile-dev/SKILL.md +++ b/apps/claude/skills/mobile-dev/SKILL.md @@ -52,6 +52,10 @@ can show a simulator or emulator with Claude Code's own simulator pane instead: tool with `action: "attach"`. Otherwise use `device_screenshot` when the user needs to see the screen. +## App Intelligence + +App Intelligence statically discovers Expo Router and React Navigation screens and combines their source dependencies for iOS and Android; it does not require App Flow or a running device. When the user asks to see the app map, call `mobile_open_app_intel` and open its `browserPanel.url` like the simulator panel. Generate or refresh with `mobile_generate_app_intel`, passing `projectPath` when no project is remembered. For a screen or file dependency question, call `mobile_app_intel` with `operation: "screen"` or `"file"`, start with depth 1, and paginate only when `nextOffset` is present. + ## Choosing a device Use the task and the app project to choose the platform. Follow the user's device diff --git a/apps/codex-plugin/test/workspace-opening.test.ts b/apps/codex-plugin/test/workspace-opening.test.ts index abd3e179..4f9f2758 100644 --- a/apps/codex-plugin/test/workspace-opening.test.ts +++ b/apps/codex-plugin/test/workspace-opening.test.ts @@ -106,3 +106,85 @@ for (const notification of ["input", "result"] as const) test(`workspace applies const unexpected = calls.filter(name => name.endsWith("_stream_session") || name.startsWith("mobile_boot")); assert.deepEqual(unexpected, []); }); + +test("mobile_open_app_intel's appIntelScreen selects that screen in the workspace", async t => { + const dom = new JSDOM('
', { + pretendToBeVisual: true, runScripts: "outside-only", url: "https://mobile-dev.test/", + }); + const window = dom.window; + window.Math.random = () => 0; + window.console.debug = () => {}; + Object.defineProperty(window.performance, "getEntriesByType", { value: () => [] }); + Object.defineProperty(window.performance, "getEntries", { value: () => [] }); + window.fetch = async () => new Response("", { status: 200 }); + window.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; + window.HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} }); + Object.defineProperty(window.HTMLElement.prototype, "clientWidth", { get: () => 400 }); + Object.defineProperty(window.HTMLElement.prototype, "clientHeight", { get: () => 800 }); + const snapshot = { + formatVersion: 1, + meta: { projectId: "p", generation: "g", analyzedAt: "2026-11-04T00:00:00Z", analyzerVersion: "t", sourceFingerprint: "f" }, + files: { "app/index.tsx": { fingerprint: "x", kind: "source", reach: 1 }, "app/profile.tsx": { fingerprint: "x", kind: "source", reach: 1 } }, + packages: {}, + screens: { + home: { title: "Home", route: "/", source: "app/index.tsx", layouts: [] }, + profile: { title: "Profile", route: "/profile", source: "app/profile.tsx", layouts: [] }, + }, + edges: [ + { from: "screen:home", to: "file:app/index.tsx", kind: "source", platforms: ["ios", "android"] }, + { from: "screen:profile", to: "file:app/profile.tsx", kind: "source", platforms: ["ios", "android"] }, + ], + diagnostics: [], + indexes: { screens: { home: ["0"], profile: ["1"] }, incoming: { "file:app/index.tsx": [0], "file:app/profile.tsx": [1] }, outgoing: { "screen:home": [0], "screen:profile": [1] } }, + }; + let tornDown = false; + function send(message: object) { + const event = new window.MessageEvent("message", { data: { jsonrpc: "2.0", ...message }, source: window }); + window.dispatchEvent(event); + } + function result(id: number | string, value: object) { send({ id, result: value }); } + let initialized = false; + const calls: string[] = []; + window.postMessage = message => { + if (message.method === "ui/initialize") { + result(message.id, { protocolVersion: "2026-01-26", hostInfo: { name: "test", version: "1" }, hostCapabilities: { serverTools: {}, serverResources: {} }, hostContext: {} }); + } else if (message.method === "ui/notifications/initialized") { + initialized = true; + } else if (message.method === "tools/call") { + const name = message.params.name; + calls.push(name); + if (name === "mobile_app_intel_snapshot") result(message.id, { content: [], structuredContent: { projectPath: "/app", snapshot, recents: ["/app"] } }); + else if (name === "mobile_list_simulators") result(message.id, { content: [], structuredContent: { connected: true, managed: false, baseUrl: "", devices: [] } }); + else result(message.id, { content: [] }); + } else if (message.id === "teardown") { + tornDown = true; + } else if (message.id !== undefined) { + result(message.id, {}); + } + }; + async function until(condition: () => boolean) { + for (let attempt = 0; attempt < 100; attempt++) { + if (condition()) return; + await setTimeout(5); + } + assert.fail("The workspace did not reach the expected host state."); + } + t.after(async () => { + send({ id: "teardown", method: "ui/resource-teardown", params: {} }); + await until(() => tornDown); + window.close(); + }); + window.eval(built.outputFiles[0].text); + await until(() => initialized); + send({ method: "ui/notifications/tool-result", params: { content: [], structuredContent: { panelTarget: { workspaceTool: "app-intel", appIntelScreen: "profile" } } } }); + // The entry-point rail starts collapsed; open it to see the selected screen. + await until(() => window.document.querySelector('button[aria-controls="app-intel-entries"]') !== null); + (window.document.querySelector('button[aria-controls="app-intel-entries"]') as HTMLButtonElement).click(); + await until(() => window.document.querySelector('[data-entry="profile"]') !== null); + await setTimeout(10); + const selected = window.document.querySelector('[data-entry="profile"]'); + assert.equal(selected?.getAttribute("aria-current"), "true"); + const home = window.document.querySelector('[data-entry="home"]'); + assert.equal(home?.getAttribute("aria-current"), "false"); + assert.deepEqual(calls.filter(name => name.startsWith("mobile_list_")), [], "App Intelligence does not start device discovery"); +}); diff --git a/apps/codex-plugin/tooling/smoke-package.mjs b/apps/codex-plugin/tooling/smoke-package.mjs index 0b9a5b71..949e0735 100644 --- a/apps/codex-plugin/tooling/smoke-package.mjs +++ b/apps/codex-plugin/tooling/smoke-package.mjs @@ -176,7 +176,7 @@ try { assert.equal(resource.contents[0].mimeType, "text/html;profile=mcp-app"); assert.ok(resource.contents[0].text.includes('id="root"')); // React creates these controls from the bundled script after the app mounts. - for (const control of ["canvas", "logs-drawer", "log-chat", "tool-select", "tool-performance", "tool-tracesift", "performance-drawer", "performance-app", "platform-select", "simulator-panels"]) { + for (const control of ["canvas", "logs-drawer", "log-chat", "tool-select", "tool-performance", "tool-app-intel", "app-intelligence-panel", "tool-tracesift", "performance-drawer", "performance-app", "platform-select", "simulator-panels"]) { assert.ok(resource.contents[0].text.includes(control), `Missing bundled UI control: ${control}`); } assert.ok(!resource.contents[0].text.includes("")); diff --git a/apps/mac/Sources/MobileDev/Tool.swift b/apps/mac/Sources/MobileDev/Tool.swift index 8c3fc178..25df1d52 100644 --- a/apps/mac/Sources/MobileDev/Tool.swift +++ b/apps/mac/Sources/MobileDev/Tool.swift @@ -3,6 +3,7 @@ enum Tool: String, CaseIterable, Identifiable { case logs case performance case storage + case appIntel = "app-intel" case tracesift var id: Self { self } @@ -12,6 +13,7 @@ enum Tool: String, CaseIterable, Identifiable { case .logs: "Logs" case .performance: "Performance" case .storage: "Storage" + case .appIntel: "App Intel" case .tracesift: "TraceSift" } } @@ -21,6 +23,7 @@ enum Tool: String, CaseIterable, Identifiable { case .logs: "terminal" case .performance: "waveform.path.ecg" case .storage: "cylinder.split.1x2" + case .appIntel: "point.3.connected.trianglepath.dotted" case .tracesift: "chart.bar.xaxis" } } diff --git a/apps/mac/web/mac-workspace.tsx b/apps/mac/web/mac-workspace.tsx index 288290fd..86afbcb6 100644 --- a/apps/mac/web/mac-workspace.tsx +++ b/apps/mac/web/mac-workspace.tsx @@ -7,6 +7,7 @@ import { PerformanceView } from "@mobile-dev/ui/components/performance-view.tsx" import { SimulatorView } from "@mobile-dev/ui/components/simulator-view.tsx"; import { StorageView } from "@mobile-dev/ui/components/storage-view.tsx"; import { TraceSiftView } from "@mobile-dev/ui/components/tracesift-view.tsx"; +import { AppIntelligenceView } from "@mobile-dev/ui/components/app-intelligence-view.tsx"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@mobile-dev/ui/components/ui/resizable.tsx"; @@ -29,6 +30,7 @@ export function MacWorkspace({ panels, tool, layout }: { panels: WorkspaceTools; const toolView = tool === "logs" ? : tool === "performance" ? : tool === "storage" ? + : tool === "app-intel" ? : tool === "tracesift" ? : ; return
diff --git a/docs/telemetry.md b/docs/telemetry.md index d57ef079..60b29c05 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -647,7 +647,7 @@ The plugin servers for Codex, Claude and Cursor serve agent-device's commands as | Node responsiveness | Automatic event-loop delay, utilization and process uptime. | | UI JavaScript heap | `ui.memory.heap_used`, `ui.memory.heap_total` and `ui.memory.heap_limit` in bytes at startup and every 30 seconds while visible, when Chromium's `performance.memory` is available. `ui.memory.supported` distinguishes unsupported contexts. These estimates do not measure total renderer or GPU memory. | | UI responsiveness | Browser tracing captures available web vitals. Custom metrics record visible animation-frame intervals, intervals over 50 ms, Event Timing interaction durations, long tasks and long animation frames where supported. | -| Product surfaces | Metrics carry `surface=app-flow`, `simulator`, `logs`, `performance`, `recording` or `comparison`, plus view, visible device layout and monitoring state. App Flow readiness/graph work, log filter time, buffered/filtered rows, performance batch processing, canvas draw time and time to first video frame help explain slow surfaces. | +| Product surfaces | Metrics carry `surface=app-flow`, `simulator`, `logs`, `performance`, `storage`, `app-intel`, `tracesift`, `recording` or `comparison`, plus view, visible device layout and monitoring state. App Flow readiness/graph work, log filter time, buffered/filtered rows, performance batch processing, App Intelligence generation and graph size, canvas draw time and time to first video frame help explain slow surfaces. | | Frame capture | `ui.screenshot.capture` measures synchronous canvas PNG encoding and base64 extraction in milliseconds for Select captures and physical iOS screenshots. `ui.screenshot.encode` measures PNG decoding, bounded JPEG encoding, and base64 extraction for chat attachments in milliseconds. Screenshot tools retain sampled MCP traces and report handled capture, attachment, and clipboard failures without image content. | | Pasted input | Command-V paste uses the existing ordered device input queue and `ui.device_input.round_trip` timing through MCP acknowledgement. It retains simulator surface/device context and existing input failure reporting. Clipboard content and validation failures are not sent to Sentry. | | Storage | Every five minutes, a server with agent-device measures its session state directory and the shared Apple runner cache in bytes, from the runtime's first start. It skips symlinks and sends only the storage kind and size. | @@ -707,6 +707,29 @@ existing centralized Sentry initialization, environment and telemetry opt-out. Native rebuilds retain matching Baguette dSYMs, and release uploads continue to upload those symbols with the plugin release. +App Intelligence uses the `app-intel` surface. `app_intel.operation.duration` and +`.samples` measure bounded generation, query, snapshot, clear and folder-picker +operations by success, failure or cancellation. The UI records generation/load/picker round +trips, surface readiness, and gauges only the counts of files and screens in the +displayed snapshot. `ui.app_intel.flow_layout` times building and placing the +per-screen dependency graph — the hop layering, its boundary grouping and the +geometry for the chosen reading direction — and `ui.app_intel.flow_nodes` gauges +how many cards it drew, which is what drives that cost. The panel no longer has a +directory treemap, so the Atlas measurements `ui.app_intel.atlas_layout`, +`ui.app_intel.atlas_cells`, `ui.app_intel.view_switch` and `ui.app_intel.zoom` are +gone rather than renamed; `flow_layout` and `flow_nodes` keep their meaning and +units, so their history stays comparable even though they now cover grouping and +placement as well. `ui.app_intel.group_collapse`, `.group_expand` and +`.group_show_all` count folding a boundary away, bringing it back and opening one +past its row cap, `ui.app_intel.boundary_highlight` counts lighting one boundary's +connectors up, `ui.app_intel.project_switch` counts picking a remembered +project instead of the host folder picker, `ui.app_intel.clear` counts throwing a +saved graph away, and `ui.app_intel.boundary_filter` +counts how often the entry-point filter is changed. All of these count the change +only; which boundary, project or direction was picked is not an attribute. Project paths, source paths, screen and package names, source +contents, queries, diagnostics, graph edges and snapshot identifiers remain local +and are never telemetry attributes. + A Fast screen sweep now runs with no decision provider unless the panel's per-project "Judge unclear screens with AI" switch is on, so the judge path described above is reachable in Fast only when that switch asked for it; with it off, a screen whose runtime evidence is incomplete is recorded unreached instead of being sent to the judge, and `app_flow.runtime.verify` emits no `judge` arrivals for that run. The series, its unit and its attributes are unchanged — the runtime/judge split simply shifts further towards `runtime` for Fast runs started with the switch off. The panel adds one UI count beside `ui.app_flow.capture_ui_states_start` and with the same shape, `ui.app_flow.judge_unclear_screens_start`, once per user-started run that has the switch on, so how often Fast pays for the decision loop is visible; it carries nothing beyond the shared surface attributes — no project path, screen, route or verdict. A Fast run with the switch off is also started straight from the panel rather than through a chat message, so it records `ui.app_flow.start` where a chat-started sweep records none; that is the existing split between the two start paths, not a new measurement. Since 0.1.141, deterministic Fast harvests route parameters from the link diff --git a/package-lock.json b/package-lock.json index 7348b808..03bfc0d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "apps/mac" ], "dependencies": { + "@babel/parser": "^8.1.0", "@base-ui/react": "^1.8.0", "@callstack/tracesift-explore": "^0.5.0", "@fontsource-variable/inter": "^5.3.0", @@ -33,6 +34,7 @@ "ajv": "8.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "enhanced-resolve": "^5.26.1", "lucide-react": "^1.49.0", "radix-ui": "^1.6.7", "react": "^19.3.0", @@ -74,11 +76,6 @@ "name": "@mobile-dev/claude", "version": "0.0.0" }, - "apps/claude-desktop": { - "name": "@mobile-dev/claude-desktop", - "version": "0.0.0", - "extraneous": true - }, "apps/codex-plugin": { "name": "@mobile-dev/codex-plugin", "version": "0.0.0" @@ -198,6 +195,22 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/generator": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", @@ -215,6 +228,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/helper-annotate-as-pure": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", @@ -379,9 +408,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "devOptional": true, "license": "MIT", "engines": { @@ -423,19 +452,49 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "devOptional": true, + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", + "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.8" + "@babel/types": "^8.1.0" }, "bin": { "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.0.0" + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser/node_modules/@babel/helper-string-parser": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.8.tgz", + "integrity": "sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser/node_modules/@babel/types": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", + "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.8", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/plugin-syntax-jsx": { @@ -583,6 +642,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/traverse": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", @@ -602,14 +677,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { @@ -5278,6 +5369,22 @@ "@types/babel__traverse": "*" } }, + "node_modules/@types/babel__core/node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@types/babel__generator": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", @@ -5299,6 +5406,22 @@ "@babel/types": "^7.0.0" } }, + "node_modules/@types/babel__template/node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@types/babel__traverse": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", @@ -6727,10 +6850,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", - "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", - "dev": true, + "version": "5.26.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.1.tgz", + "integrity": "sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -7391,7 +7513,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -9874,6 +9995,22 @@ "node": ">=20.18.1" } }, + "node_modules/shadcn/node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/shadcn/node_modules/undici": { "version": "7.30.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", @@ -10259,7 +10396,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/package.json b/package.json index afbba047..145e6554 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "test:visual:docker": "node tooling/hosts/docker.mjs" }, "dependencies": { + "@babel/parser": "^8.1.0", "@base-ui/react": "^1.8.0", "@callstack/tracesift-explore": "^0.5.0", "@fontsource-variable/inter": "^5.3.0", @@ -86,6 +87,7 @@ "ajv": "8.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "enhanced-resolve": "^5.26.1", "lucide-react": "^1.49.0", "radix-ui": "^1.6.7", "react": "^19.3.0", diff --git a/packages/core/src/app-intelligence/analyzer.ts b/packages/core/src/app-intelligence/analyzer.ts new file mode 100644 index 00000000..48fdd67c --- /dev/null +++ b/packages/core/src/app-intelligence/analyzer.ts @@ -0,0 +1,333 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFile, readdir, realpath } from "node:fs/promises"; +import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { parse } from "@babel/parser"; +import enhancedResolve from "enhanced-resolve"; +import type { AppIntelSnapshot, IntelDiagnostic, IntelEdge, IntelPlatform, IntelRef } from "@mobile-dev/shared/app-intelligence.ts"; +import { fileRef, packageRef, screenRef } from "@mobile-dev/shared/app-intelligence.ts"; + +const ignored = new Set(["node_modules", ".git", ".expo", ".next", ".yarn", "dist", "build", "out", "coverage", "Pods", ".gradle"]); +const sourcePattern = /\.(?:[cm]?[jt]sx?)$/; +const assetPattern = /\.(?:png|jpe?g|gif|webp|svg)$/i; +const extensions = (platform: IntelPlatform) => ["js", "jsx", "json", "ts", "tsx", "mjs", "cjs", "mts", "cts"].flatMap(ext => [`.${platform}.${ext}`, `.native.${ext}`, `.${ext}`]); +const fingerprint = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +const portable = (root: string, path: string) => relative(root, path).split(sep).join("/"); +const inside = (root: string, path: string) => { const rel = relative(root, path); return rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith("/"); }; +const packageName = (specifier: string) => specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0]; + +type Registration = { name: string; local?: string; specifier?: string }; +type ParsedSource = { path: string; code: string; imports: Array<{ specifier: string; typeOnly: boolean; dynamic: boolean; local?: string }>; navigation: string[]; registrations: Registration[]; declared: Set }; + +function walkAst(node: unknown, visit: (node: Record) => void) { + if (!node || typeof node !== "object") return; + const record = node as Record; + if (typeof record.type === "string") visit(record); + for (const [key, child] of Object.entries(record)) { + if (key === "loc" || key === "start" || key === "end" || key === "extra") continue; + if (Array.isArray(child)) for (const item of child) walkAst(item, visit); + else if (child && typeof child === "object") walkAst(child, visit); + } +} + +const isModuleCall = (node: Record) => + (node.type === "CallExpression" || node.type === "OptionalCallExpression") && + node.arguments?.[0]?.type === "StringLiteral" && + (node.callee?.type === "Import" || node.callee?.name === "require"); + +/** + * The component a `` renders, however it was written: `component={Screen}`, + * `getComponent={() => Screen}`, `component={lazy(() => import('…'))}` and + * `getComponent={() => require('…').Screen}` all name the same file. A specifier beats a local + * name because `require('…').Screen` carries both and only the specifier names the module. + */ +function componentRef(expression: unknown): Pick { + let local: string | undefined, specifier: string | undefined; + walkAst(expression, node => { + if (specifier) return; + if (isModuleCall(node)) specifier = node.arguments[0].value; + else if (!local && node.type === "Identifier" && node.name !== "require") local = node.name; + }); + return specifier ? { specifier } : { local }; +} + +function parseSource(path: string, code: string, diagnostics: IntelDiagnostic[]): ParsedSource { + const found: ParsedSource = { path, code, imports: [], navigation: [], registrations: [], declared: new Set() }; + try { + const ast = parse(code, { sourceType: "unambiguous", errorRecovery: true, plugins: ["jsx", "typescript", "decorators-legacy"] }); + walkAst(ast, node => { + if (node.type === "ImportDeclaration" && typeof node.source?.value === "string") { + for (const specifier of node.specifiers?.length ? node.specifiers : [undefined]) found.imports.push({ specifier: node.source.value, typeOnly: node.importKind === "type", dynamic: false, local: specifier?.local?.name }); + } else if ((node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") && typeof node.source?.value === "string") { + found.imports.push({ specifier: node.source.value, typeOnly: node.exportKind === "type", dynamic: false }); + } else if ((node.type === "CallExpression" || node.type === "OptionalCallExpression") && node.arguments?.[0]?.type === "StringLiteral") { + // `require` counts: React Navigation registers plenty of screens through one. + if (isModuleCall(node)) found.imports.push({ specifier: node.arguments[0].value, typeOnly: false, dynamic: true }); + const method = node.callee?.property?.name ?? node.callee?.name; + if (["navigate", "push", "replace"].includes(method)) found.navigation.push(node.arguments[0].value); + } else if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") { + if (typeof node.id?.name === "string") found.declared.add(node.id.name); + } else if (node.type === "VariableDeclarator" && typeof node.id?.name === "string") { + found.declared.add(node.id.name); + } else if (node.type === "JSXOpeningElement") { + const name = node.name?.type === "JSXMemberExpression" ? node.name.property?.name : node.name?.name; + if (name !== "Screen") return; + const attrs = new Map((node.attributes ?? []).filter((item: any) => item.type === "JSXAttribute").map((item: any) => [item.name?.name, item.value])); + const screenName = (attrs.get("name") as any)?.value; + const component = (attrs.get("component") ?? attrs.get("getComponent")) as any; + if (typeof screenName === "string") found.registrations.push({ name: screenName, ...componentRef(component?.expression) }); + } + }); + } catch (error) { + diagnostics.push({ code: "parse-error", file: path, message: error instanceof Error ? error.message.split("\n")[0] : String(error) }); + } + return found; +} + +/** tsconfig.json is JSONC: comments and trailing commas are legal there and fatal to `JSON.parse`. */ +export function parseJsonc(text: string): any { + let out = "", inString = false, escaped = false, inLine = false, inBlock = false; + for (let index = 0; index < text.length; index++) { + const char = text[index], next = text[index + 1]; + if (inLine) { if (char === "\n") { inLine = false; out += char; } continue; } + if (inBlock) { if (char === "*" && next === "/") { inBlock = false; index++; } continue; } + if (inString) { + out += char; + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === "/" && next === "/") { inLine = true; continue; } + if (char === "/" && next === "*") { inBlock = true; index++; continue; } + if (char === '"') inString = true; + out += char; + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1")); +} + +/** `extends` targets are a relative path or a package, with `.json` and `/tsconfig.json` implied. */ +async function resolveExtends(from: string, target: string): Promise { + const candidates: string[] = []; + const add = (base: string) => { + candidates.push(base); + if (!extname(base)) candidates.push(`${base}.json`, join(base, "tsconfig.json")); + }; + if (target.startsWith(".") || isAbsolute(target)) add(resolve(from, target)); + else for (let directory = from; ; directory = dirname(directory)) { + add(join(directory, "node_modules", target)); + if (dirname(directory) === directory) break; + } + for (const candidate of candidates) try { return await realpath(candidate); } catch { /* try the next shape */ } + return null; +} + +type Alias = { prefix: string; suffix: string; targets: string[] }; + +/** + * Path aliases from the tsconfig chain. The nearest config that declares `paths` wins, and its + * relative targets resolve against its own directory (or its `baseUrl`), as TypeScript does. + */ +async function readAliases(root: string, diagnostics: IntelDiagnostic[]): Promise { + const aliases: Alias[] = []; + const seen = new Set(); + let file: string | null = join(root, "tsconfig.json"); + for (let depth = 0; file && depth < 10; depth++) { + if (seen.has(file)) break; + seen.add(file); + let config: any; + try { + config = parseJsonc(await readFile(file, "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") diagnostics.push({ code: "tsconfig", file: portable(root, file), message: "Could not read tsconfig path aliases; normal module resolution was used." }); + return aliases; + } + const paths = config.compilerOptions?.paths; + if (paths && typeof paths === "object") { + const base = resolve(dirname(file), config.compilerOptions?.baseUrl ?? "."); + for (const [pattern, targets] of Object.entries(paths)) { + if (!Array.isArray(targets)) continue; + const star = pattern.indexOf("*"); + aliases.push({ prefix: star < 0 ? pattern : pattern.slice(0, star), suffix: star < 0 ? "" : pattern.slice(star + 1), targets: targets.filter((value): value is string => typeof value === "string").map(value => resolve(base, value)) }); + } + return aliases; + } + const parent = Array.isArray(config.extends) ? config.extends.at(-1) : config.extends; + file = typeof parent === "string" ? await resolveExtends(dirname(file), parent) : null; + } + return aliases; +} + +/** The package a resolved path belongs to, from the last `node_modules` segment it sits under. */ +function packageFrom(path: string, specifier: string): string { + const segments = path.split(sep); + const marker = segments.lastIndexOf("node_modules"); + if (marker < 0 || !segments[marker + 1]) return packageName(specifier); + const first = segments[marker + 1]; + return first.startsWith("@") && segments[marker + 2] ? `${first}/${segments[marker + 2]}` : first; +} + +function expoRoute(root: string, path: string): { id: string; title: string; route: string } | null { + const rel = portable(root, path); + const marker = rel.match(/(?:^|\/)(app)\/(.+)\.(?:[cm]?[jt]sx?)$/); + if (!marker || /(?:^|\/)_(?:layout|sitemap)$/.test(marker[2]) || /(?:^|\/)\+/.test(marker[2])) return null; + const segments = marker[2].split("/").filter(segment => !/^\(.*\)$/.test(segment)); + const last = segments.at(-1); + if (last === "index") segments.pop(); + const route = `/${segments.join("/")}` || "/"; + return { id: `expo:${marker[2]}`, title: last === "index" ? segments.at(-1) ?? "Home" : last ?? "Home", route }; +} + +export async function analyzeApp(projectPath: string, options: { signal?: AbortSignal; onProgress?: (message: string) => void } = {}): Promise { + const root = await realpath(projectPath); + const projectId = fingerprint(root).slice(0, 24); + const generation = randomUUID(); + const diagnostics: IntelDiagnostic[] = []; + const paths: string[] = []; + const check = () => options.signal?.throwIfAborted(); + async function walk(directory: string): Promise { + check(); + for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isSymbolicLink()) continue; + const path = join(directory, entry.name); + if (entry.isDirectory() && !ignored.has(entry.name)) await walk(path); + else if (entry.isFile() && (sourcePattern.test(entry.name) || assetPattern.test(entry.name))) paths.push(path); + } + } + options.onProgress?.("Discovering source files"); + await walk(root); + const files: AppIntelSnapshot["files"] = Object.create(null); + const parsed = new Map(); + for (const [index, path] of paths.entries()) { + check(); + const content = await readFile(path); + const rel = portable(root, path); + files[rel] = { fingerprint: fingerprint(content), kind: sourcePattern.test(path) ? "source" : "asset", reach: 0 }; + if (sourcePattern.test(path)) parsed.set(rel, parseSource(rel, content.toString("utf8"), diagnostics)); + if (index > 0 && index % 100 === 0) options.onProgress?.(`Parsed ${index} of ${paths.length} files`); + } + const aliases = await readAliases(root, diagnostics); + const requestsFor = (specifier: string) => { + const requests = [specifier]; + for (const alias of aliases) if (specifier.startsWith(alias.prefix) && specifier.endsWith(alias.suffix)) { + const middle = specifier.slice(alias.prefix.length, alias.suffix ? -alias.suffix.length : undefined); + for (const target of alias.targets) requests.unshift(target.replaceAll("*", () => middle)); + } + return requests; + }; + const resolveFor = new Map string | false>(); + for (const platform of ["ios", "android"] as const) resolveFor.set(platform, enhancedResolve.create.sync({ extensions: extensions(platform), mainFields: ["react-native", "module", "main"], conditionNames: ["react-native", platform, "import", "require", "default"], symlinks: false })); + const realCache = new Map(); + const realOf = async (path: string) => { + const cached = realCache.get(path); + if (cached) return cached; + let real = path; + try { real = await realpath(path); } catch { /* a virtual or missing target keeps its own path */ } + realCache.set(path, real); + return real; + }; + const edges: IntelEdge[] = []; + const packages: AppIntelSnapshot["packages"] = Object.create(null); + const localTargets = new Map>(); + const specifierTargets = new Map>(); + for (const source of parsed.values()) { + const locals = new Map(); + const specifiers = new Map(); + for (const item of source.imports) { + check(); + const resolved = new Map(); + for (const platform of ["ios", "android"] as const) { + for (const request of requestsFor(item.specifier)) { + let result: string | false; + try { result = resolveFor.get(platform)!(resolve(root, dirname(source.path)), request); } + catch { continue; /* try the next alias, or report it as a diagnostic below */ } + if (!result) continue; + // A workspace package is symlinked into node_modules but its real path is app code, so + // the real path decides, and everything else collapses to one leaf for its package. + const real = await realOf(result); + const appCode = inside(root, real) && !relative(root, real).split(sep).includes("node_modules"); + if (!appCode) { + const name = packageFrom(result, item.specifier); + packages[name] ??= { name, reach: 0, entry: inside(root, result) ? portable(root, result) : null }; + const key = packageRef(name); + resolved.set(key, [...(resolved.get(key) ?? []), platform]); + break; + } + const target = portable(root, real); + const key = fileRef(target); + resolved.set(key, [...(resolved.get(key) ?? []), platform]); + if (item.local) locals.set(item.local, target); + specifiers.set(item.specifier, target); + break; + } + } + if (resolved.size === 0 && !item.specifier.startsWith(".") && !item.specifier.startsWith("/") && !item.specifier.startsWith("#")) { + const name = packageName(item.specifier); packages[name] ??= { name, reach: 0, entry: null }; + resolved.set(packageRef(name), ["ios", "android"]); + } + if (resolved.size === 0) diagnostics.push({ code: "unresolved-import", file: source.path, message: `Could not resolve ${item.specifier} for iOS or Android.` }); + for (const [to, platforms] of resolved) edges.push({ from: fileRef(source.path), to: to as any, kind: "import", specifier: item.specifier, platforms, dynamic: item.dynamic || undefined, typeOnly: item.typeOnly || undefined }); + } + localTargets.set(source.path, locals); + specifierTargets.set(source.path, specifiers); + } + const screens: AppIntelSnapshot["screens"] = Object.create(null); + const screenByName = new Map(); + for (const [path, source] of parsed) { + const expo = expoRoute(root, join(root, path)); + if (expo) { + screens[expo.id] = { title: expo.title, route: expo.route, source: path, layouts: [] }; + screenByName.set(expo.title.toLowerCase(), expo.id); screenByName.set(expo.route.toLowerCase(), expo.id); + } + for (const registration of source.registrations) { + const id = `navigation:${registration.name}`; + const target = registration.specifier ? specifierTargets.get(path)?.get(registration.specifier) + : registration.local ? localTargets.get(path)?.get(registration.local) : undefined; + // Falling back to the registering file would give every screen that file's whole import + // graph, so an unresolved component is reported rather than papered over. + // A nested navigator declared in this very file really does live here; only a component + // this file neither declares nor imports is a gap worth reporting. + const own = registration.local ? source.declared.has(registration.local) : false; + if (!target && !own) diagnostics.push({ code: "unresolved-screen", file: path, message: `Could not tell which file renders ${registration.name}; it is mapped to its navigator.` }); + screens[id] = { title: registration.name, route: null, source: target ?? path, layouts: [] }; + screenByName.set(registration.name.toLowerCase(), id); + } + } + for (const [id, screen] of Object.entries(screens)) { + if (!screen.source) continue; + const parts = dirname(screen.source).split("/"); + while (parts.length) { + const directory = parts.join("/"); + const layout = Object.keys(files).find(path => path.startsWith(`${directory}/_layout.`) && sourcePattern.test(path)); + if (layout) screen.layouts.push(layout); + parts.pop(); + } + edges.push({ from: screenRef(id), to: fileRef(screen.source), kind: "source", platforms: ["ios", "android"] }); + for (const layout of screen.layouts) edges.push({ from: screenRef(id), to: fileRef(layout), kind: "layout", platforms: ["ios", "android"] }); + } + const owners = new Map(); + for (const [id, screen] of Object.entries(screens)) if (screen.source) owners.set(screen.source, [...(owners.get(screen.source) ?? []), id]); + for (const source of parsed.values()) for (const name of source.navigation) { + const to = screenByName.get(name.toLowerCase()); + for (const from of owners.get(source.path) ?? []) edges.push({ from: screenRef(from), to: to ? screenRef(to) : null, kind: "navigation", specifier: name, platforms: ["ios", "android"] }); + } + const unique = [...new Map(edges.map(edge => [JSON.stringify(edge), edge])).values()]; + const indexes: AppIntelSnapshot["indexes"] = { screens: Object.create(null), incoming: Object.create(null), outgoing: Object.create(null) }; + for (const [id, screen] of Object.entries(screens)) for (const key of new Set([id, screen.title, screen.route].filter(Boolean).map(value => String(value).toLowerCase()))) (indexes.screens[key] ??= []).push(id); + unique.forEach((edge, index) => { (indexes.outgoing[edge.from] ??= []).push(index); if (edge.to) (indexes.incoming[edge.to] ??= []).push(index); }); + for (const id of Object.keys(screens)) { + check(); + const queue: IntelRef[] = [screenRef(id)]; + const visited = new Set(queue); + for (let cursor = 0; cursor < queue.length; cursor++) for (const index of indexes.outgoing[queue[cursor]] ?? []) { + const edge = unique[index]; + if (!edge.to || edge.kind === "navigation" || visited.has(edge.to)) continue; + visited.add(edge.to); + if (edge.to.startsWith("file:")) { const file = files[edge.to.slice(5)]; if (file) file.reach++; } + // A package is a leaf: its own internals belong to its author, not to this app's hierarchy. + else if (edge.to.startsWith("package:")) { const pkg = packages[edge.to.slice(8)]; if (pkg) pkg.reach++; continue; } + queue.push(edge.to); + } + } + const sourceFingerprint = fingerprint(Object.entries(files).map(([path, file]) => `${path}:${file.fingerprint}`).join("\n")); + return { formatVersion: 1, meta: { projectId, generation, analyzedAt: new Date().toISOString(), analyzerVersion: "mobile-dev-1", sourceFingerprint }, files, packages, screens, edges: unique, diagnostics, indexes }; +} diff --git a/packages/core/src/app-intelligence/lookup.ts b/packages/core/src/app-intelligence/lookup.ts new file mode 100644 index 00000000..96c02d7f --- /dev/null +++ b/packages/core/src/app-intelligence/lookup.ts @@ -0,0 +1,53 @@ +import type { AppIntelQuery, AppIntelSnapshot, IntelEdge, IntelRef } from "@mobile-dev/shared/app-intelligence.ts"; +import { fileRef, screenRef } from "@mobile-dev/shared/app-intelligence.ts"; + +const MAX_BYTES = 8000; +const screenItem = (id: string, snapshot: AppIntelSnapshot) => ({ kind: "screen", id, ...snapshot.screens[id] }); +const edgeItem = (edge: IntelEdge) => ({ kind: "relationship", from: edge.from, to: edge.to, relationship: edge.kind, platforms: edge.platforms, specifier: edge.specifier }); + +export function lookupAppIntel(snapshot: AppIntelSnapshot, input: AppIntelQuery) { + const query = input.query.toLowerCase(); + const items: Record[] = []; + let start: IntelRef | undefined; + let note: string | undefined; + if (input.operation === "file") { + if (snapshot.files[input.query]) start = fileRef(input.query); + else note = "File not found. Search for its project-relative path."; + } else if (input.operation === "screen") { + const hits = snapshot.screens[input.query] ? [input.query] : snapshot.indexes.screens[query] ?? []; + if (hits.length === 1) start = screenRef(hits[0]); + else { note = hits.length ? "Ambiguous screen. Use one of these exact IDs." : "Screen not found. Search by title or route."; for (const id of hits) items.push(screenItem(id, snapshot)); } + } else { + for (const [id, screen] of Object.entries(snapshot.screens)) if (`${id} ${screen.title} ${screen.route ?? ""}`.toLowerCase().includes(query)) items.push(screenItem(id, snapshot)); + for (const [path, file] of Object.entries(snapshot.files)) if (path.toLowerCase().includes(query)) items.push({ kind: "file", path, fileKind: file.kind }); + } + if (start) { + const queue: Array<[IntelRef, number]> = [[start, 0]]; + const visited = new Set(); + for (let cursor = 0; cursor < queue.length; cursor++) { + const [ref, distance] = queue[cursor]; if (visited.has(ref)) continue; visited.add(ref); + if (ref.startsWith("screen:")) items.push(screenItem(ref.slice(7), snapshot)); + else if (ref.startsWith("file:")) items.push({ kind: "file", path: ref.slice(5), fileKind: snapshot.files[ref.slice(5)]?.kind ?? "source" }); + else items.push({ kind: "package", name: ref.slice(8) }); + const ids = input.direction === "incoming" ? snapshot.indexes.incoming[ref] ?? [] : input.direction === "outgoing" ? snapshot.indexes.outgoing[ref] ?? [] : [...(snapshot.indexes.incoming[ref] ?? []), ...(snapshot.indexes.outgoing[ref] ?? [])]; + for (const id of new Set(ids)) { + const edge = snapshot.edges[id]; + const ownership = edge.kind === "source" || edge.kind === "layout"; + if (!ownership && distance >= input.depth) continue; + items.push(edgeItem(edge)); + const next = edge.from === ref ? edge.to : edge.from; if (next) queue.push([next, ownership ? distance : distance + 1]); + } + } + } + const unique = [...new Map(items.map(item => [JSON.stringify(item), item])).values()]; + const page = { meta: snapshot.meta, summary: { files: Object.keys(snapshot.files).length, screens: Object.keys(snapshot.screens).length, packages: Object.keys(snapshot.packages).length, diagnostics: snapshot.diagnostics.length }, items: [] as Record[], total: unique.length, nextOffset: null as number | null, note }; + let cursor = input.offset; + while (cursor < unique.length && page.items.length < input.limit) { + page.items.push(unique[cursor]); + if (Buffer.byteLength(JSON.stringify(page)) > MAX_BYTES) { page.items.pop(); break; } + cursor++; + } + page.nextOffset = cursor < unique.length ? cursor : null; + return page; +} + diff --git a/packages/core/src/app-intelligence/service.ts b/packages/core/src/app-intelligence/service.ts new file mode 100644 index 00000000..76527d91 --- /dev/null +++ b/packages/core/src/app-intelligence/service.ts @@ -0,0 +1,103 @@ +import { randomUUID } from "node:crypto"; +import { Worker } from "node:worker_threads"; +import type { AppIntelSnapshot, AppIntelStatus } from "@mobile-dev/shared/app-intelligence.ts"; +import { clearAppIntel, readAppIntel, readRecentProjects, readRememberedProject, rememberProject, validateProjectPath, writeAppIntel } from "./storage.ts"; + +type Run = NonNullable & { controller: AbortController; worker?: Worker }; + +type WorkerMessage = + | { type: "progress"; message: string } + | { type: "result"; snapshot: AppIntelSnapshot } + | { type: "error"; name: string; message: string }; + +function workerUrl() { + return new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./app-intelligence-worker.mjs", import.meta.url); +} + +export class AppIntelligenceService { + private runs = new Map(); + + async project(path?: string, remember = false) { + const candidate = path ?? await readRememberedProject(); + if (!candidate) throw new Error("No project selected. Choose a project folder in App Intelligence or pass projectPath."); + const projectPath = await validateProjectPath(candidate); + if (remember) await rememberProject(projectPath); + return projectPath; + } + + async status(path?: string): Promise { + const projectPath = await this.project(path); + const snapshot = await readAppIntel(projectPath); + const run = this.runs.get(projectPath); + return { projectPath, run: run ? { runId: run.runId, phase: run.phase, message: run.message } : null, snapshot: snapshot ? { analyzedAt: snapshot.meta.analyzedAt, generation: snapshot.meta.generation } : null }; + } + + async snapshot(path?: string, remember = false): Promise<{ projectPath: string; snapshot: AppIntelSnapshot | null; recents: string[] }> { + const projectPath = await this.project(path, remember); + return { projectPath, snapshot: await readAppIntel(projectPath), recents: await readRecentProjects() }; + } + + /** The projects offered when nothing is selected yet, so the picker is never empty on a cold start. */ + async recents(): Promise { return await readRecentProjects(); } + + async generate(path?: string, signal?: AbortSignal): Promise<{ projectPath: string; snapshot: AppIntelSnapshot }> { + const projectPath = await this.project(path, true); + if (this.runs.has(projectPath)) throw new Error("App Intelligence generation is already running for this project."); + const controller = new AbortController(); + const abort = () => controller.abort(signal?.reason); + signal?.addEventListener("abort", abort, { once: true }); + const run: Run = { runId: randomUUID(), phase: "discovering", message: "Discovering project", controller }; + this.runs.set(projectPath, run); + try { + const snapshot = await new Promise((resolve, reject) => { + const worker = new Worker(workerUrl(), { workerData: { projectPath } }); + run.worker = worker; + let settled = false; + const finish = (operation: () => void) => { + if (settled) return; + settled = true; + controller.signal.removeEventListener("abort", cancel); + operation(); + }; + const cancel = () => { + void worker.terminate(); + finish(() => reject(controller.signal.reason ?? new Error("Generation cancelled."))); + }; + controller.signal.addEventListener("abort", cancel, { once: true }); + worker.on("message", (message: WorkerMessage) => { + if (message.type === "progress") { + run.phase = "analyzing"; + run.message = message.message; + } else if (message.type === "result") finish(() => resolve(message.snapshot)); + else { + const error = new Error(message.message); + error.name = message.name; + finish(() => reject(error)); + } + }); + worker.on("error", error => finish(() => reject(error))); + worker.on("exit", code => { + if (code !== 0 && !controller.signal.aborted) finish(() => reject(new Error(`App Intelligence worker exited with code ${code}.`))); + }); + if (controller.signal.aborted) cancel(); + }); + controller.signal.throwIfAborted(); run.phase = "saving"; run.message = "Saving snapshot"; + await writeAppIntel(projectPath, snapshot); + return { projectPath, snapshot }; + } finally { signal?.removeEventListener("abort", abort); this.runs.delete(projectPath); } + } + + async clear(path?: string): Promise<{ projectPath: string; snapshot: null; recents: string[] }> { + const projectPath = await this.project(path); + if (this.runs.has(projectPath)) throw new Error("App Intelligence is still generating for this project. Cancel it first."); + await clearAppIntel(projectPath); + return { projectPath, snapshot: null, recents: await readRecentProjects() }; + } + + async cancel(path?: string) { + const projectPath = await this.project(path); + const run = this.runs.get(projectPath); + run?.controller.abort(new Error("Generation cancelled.")); + } +} + diff --git a/packages/core/src/app-intelligence/storage.ts b/packages/core/src/app-intelligence/storage.ts new file mode 100644 index 00000000..79d555e7 --- /dev/null +++ b/packages/core/src/app-intelligence/storage.ts @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { AppIntelSnapshot } from "@mobile-dev/shared/app-intelligence.ts"; + +export const APP_INTEL_DIRECTORY = join(homedir(), "Library/Application Support/mobile-dev/app-intelligence"); +const preferencePath = join(APP_INTEL_DIRECTORY, "preferences.json"); +const projectKey = (path: string) => createHash("sha256").update(path).digest("hex"); + +export async function validateProjectPath(path: string): Promise { + if (!path || path.length > 4096) throw new Error("Choose a valid project folder."); + const resolved = await realpath(path); + if (!(await stat(resolved)).isDirectory()) throw new Error("The selected project path is not a directory."); + return resolved; +} + +async function atomicWrite(path: string, content: string) { + await mkdir(APP_INTEL_DIRECTORY, { recursive: true }); + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporary, content, { mode: 0o600 }); + await rename(temporary, path); +} + +/** How many projects the picker offers. Enough to switch between apps, short enough to scan. */ +const RECENT_LIMIT = 8; +export type AppIntelPreferences = { projectPath: string | null; recents: string[] }; + +export async function readPreferences(): Promise { + try { + const value = JSON.parse(await readFile(preferencePath, "utf8")); + const projectPath = typeof value.projectPath === "string" ? value.projectPath : null; + const stored = Array.isArray(value.recents) ? value.recents.filter((entry: unknown): entry is string => typeof entry === "string") : []; + // An older preferences file holds one path and no list, so the chosen project seeds the list. + const recents = [...new Set([...(projectPath ? [projectPath] : []), ...stored])].slice(0, RECENT_LIMIT); + return { projectPath, recents }; + } catch { return { projectPath: null, recents: [] }; } +} + +export async function readRememberedProject(): Promise { + return (await readPreferences()).projectPath; +} + +export async function readRecentProjects(): Promise { + return (await readPreferences()).recents; +} + +/** Most recent first, deduplicated, so the picker reflects the order projects were last opened. */ +export async function rememberProject(projectPath: string) { + const { recents } = await readPreferences(); + const next = [projectPath, ...recents.filter(entry => entry !== projectPath)].slice(0, RECENT_LIMIT); + await atomicWrite(preferencePath, JSON.stringify({ projectPath, recents: next })); +} +export const snapshotPath = (projectPath: string) => join(APP_INTEL_DIRECTORY, `${projectKey(projectPath)}.json`); + +export async function readAppIntel(projectPath: string): Promise { + try { + const value = JSON.parse(await readFile(snapshotPath(projectPath), "utf8")) as AppIntelSnapshot; + if (value.formatVersion !== 1 || !value.meta || !value.screens || !value.indexes) throw new Error("Unsupported App Intelligence snapshot. Generate it again."); + return value; + } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } +} + +export async function writeAppIntel(projectPath: string, snapshot: AppIntelSnapshot) { + await atomicWrite(snapshotPath(projectPath), JSON.stringify(snapshot)); +} + +/** Drops one project's saved graph. The project stays remembered, so it can be generated again. */ +export async function clearAppIntel(projectPath: string) { + await rm(snapshotPath(projectPath), { force: true }); +} + diff --git a/packages/core/src/app-intelligence/tools.ts b/packages/core/src/app-intelligence/tools.ts new file mode 100644 index 00000000..91d0621a --- /dev/null +++ b/packages/core/src/app-intelligence/tools.ts @@ -0,0 +1,106 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import { z } from "zod"; +import { appIntelQueryInput, generateAppIntelInput } from "@mobile-dev/shared/app-intelligence.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; +import { pickProjectFolder } from "../profile-picker.ts"; +import { lookupAppIntel } from "./lookup.ts"; +import { AppIntelligenceService } from "./service.ts"; +import { recordAppIntelOperation } from "../telemetry.ts"; + +const read = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; +const write = { readOnlyHint: false, destructiveHint: false, openWorldHint: false }; +const destructive = { readOnlyHint: false, destructiveHint: true, openWorldHint: false }; +const text = (value: unknown) => ({ content: [{ type: "text" as const, text: typeof value === "string" ? value : JSON.stringify(value) }], structuredContent: typeof value === "object" && value !== null ? value as Record : undefined }); +const failure = (error: unknown) => ({ isError: true as const, content: [{ type: "text" as const, text: errorMessage(error) }] }); + +export function registerAppIntelligenceTools(server: McpServer, service: AppIntelligenceService) { + server.registerTool("mobile_generate_app_intel", { + title: "Generate App Intelligence", + description: "Analyze a React Native project's Expo Router and React Navigation screens plus its static import graph for iOS and Android. App Intelligence discovers screens itself; App Flow does not need to run first. Pass projectPath to override the folder remembered by the panel. When the user asked to create or see the app's map, follow this with mobile_open_app_intel to render it in chat.", + inputSchema: generateAppIntelInput.shape, annotations: write, + }, async (input, context) => { + const startedAt = performance.now(); + try { + const result = await service.generate(input.projectPath, context.signal); + recordAppIntelOperation("generate", "success", performance.now() - startedAt); + return text({ projectPath: result.projectPath, analyzedAt: result.snapshot.meta.analyzedAt, generation: result.snapshot.meta.generation, files: Object.keys(result.snapshot.files).length, screens: Object.keys(result.snapshot.screens).length, packages: Object.keys(result.snapshot.packages).length, diagnostics: result.snapshot.diagnostics.length }); + } catch (error) { recordAppIntelOperation("generate", context.signal.aborted ? "cancelled" : "failed", performance.now() - startedAt); return failure(error); } + }); + + server.registerTool("mobile_app_intel", { + title: "Query App Intelligence", + description: "Query a saved React Native App Intelligence graph. Use operation=screen for a screen's dependencies, file for importers/imports, and search when the exact screen ID or project-relative file is unknown. direction=incoming finds consumers; outgoing finds dependencies. Read the compact summary first and paginate only when nextOffset is present. Pass projectPath to override the folder remembered by the panel. When the user asked about a screen by name and wants it shown, not just answered, search here for its ID, then call mobile_open_app_intel with that screen ID to render its dependency graph in chat.", + inputSchema: appIntelQueryInput.shape, annotations: read, + }, async raw => { + const startedAt = performance.now(); + try { + const input = appIntelQueryInput.parse(raw); + const { projectPath, snapshot } = await service.snapshot(input.projectPath); + if (!snapshot) return text(`No App Intelligence snapshot exists for ${projectPath}. Run mobile_generate_app_intel first.`); + const result = text({ projectPath, ...lookupAppIntel(snapshot, input) }); recordAppIntelOperation("query", "success", performance.now() - startedAt); return result; + } catch (error) { recordAppIntelOperation("query", "failed", performance.now() - startedAt); return failure(error); } + }); + + registerAppTool(server, "mobile_pick_app_intel_project", { + title: "Choose App Intelligence project", description: "Open the host folder picker for App Intelligence. Pressing Cancel keeps the current project.", + inputSchema: {}, annotations: write, _meta: { ui: { visibility: ["app"] } }, + }, async () => { + const startedAt = performance.now(); + try { + const picked = await pickProjectFolder(); + if (!picked.picked) { recordAppIntelOperation("pick", "cancelled", performance.now() - startedAt); return text({ picked: false }); } + const projectPath = await service.project(picked.path, true); + recordAppIntelOperation("pick", "success", performance.now() - startedAt); + return text({ picked: true, projectPath }); + } catch (error) { recordAppIntelOperation("pick", "failed", performance.now() - startedAt); return failure(error); } + }); + + registerAppTool(server, "mobile_select_app_intel_project", { + title: "Switch App Intelligence project", description: "Switch App Intelligence to a project the panel already knows, and remember it as the current one.", + inputSchema: { projectPath: z.string().min(1).max(4096) }, annotations: write, _meta: { ui: { visibility: ["app"] } }, + }, async input => { + const startedAt = performance.now(); + try { const result = text(await service.snapshot(input.projectPath, true)); recordAppIntelOperation("pick", "success", performance.now() - startedAt); return result; } + catch (error) { recordAppIntelOperation("pick", "failed", performance.now() - startedAt); return failure(error); } + }); + + registerAppTool(server, "mobile_app_intel_snapshot", { + title: "Read App Intelligence snapshot", description: "Read the selected App Intelligence snapshot for the shared visualization.", + inputSchema: generateAppIntelInput.shape, annotations: read, _meta: { ui: { visibility: ["app"] } }, + }, async input => { + const startedAt = performance.now(); + try { const result = text(await service.snapshot(input.projectPath)); recordAppIntelOperation("snapshot", "success", performance.now() - startedAt); return result; } + catch (error) { + recordAppIntelOperation("snapshot", "failed", performance.now() - startedAt); + const recents = await service.recents().catch(() => []); + return recents.length ? { ...failure(error), structuredContent: { recents } } : failure(error); + } + }); + + registerAppTool(server, "mobile_app_intel_status", { + title: "Read App Intelligence status", description: "Read the selected project and current App Intelligence generation state.", + inputSchema: generateAppIntelInput.shape, annotations: read, _meta: { ui: { visibility: ["app"] } }, + }, async input => { + try { return text(await service.status(input.projectPath)); } + catch (error) { return failure(error); } + }); + + registerAppTool(server, "mobile_clear_app_intel", { + title: "Clear the App Intelligence graph", description: "Delete the saved App Intelligence graph for the selected project. The project stays selected and can be analyzed again.", + inputSchema: generateAppIntelInput.shape, annotations: destructive, _meta: { ui: { visibility: ["app"] } }, + }, async input => { + const startedAt = performance.now(); + try { const result = text(await service.clear(input.projectPath)); recordAppIntelOperation("clear", "success", performance.now() - startedAt); return result; } + catch (error) { recordAppIntelOperation("clear", "failed", performance.now() - startedAt); return failure(error); } + }); + + registerAppTool(server, "mobile_cancel_app_intel", { + title: "Cancel App Intelligence generation", description: "Cancel App Intelligence generation before its atomic save.", + inputSchema: generateAppIntelInput.shape, annotations: write, _meta: { ui: { visibility: ["app"] } }, + }, async input => { + try { await service.cancel(input.projectPath); return text({ cancelled: true }); } + catch (error) { return failure(error); } + }); +} + diff --git a/packages/core/src/app-intelligence/worker.ts b/packages/core/src/app-intelligence/worker.ts new file mode 100644 index 00000000..281e408c --- /dev/null +++ b/packages/core/src/app-intelligence/worker.ts @@ -0,0 +1,27 @@ +import { parentPort, workerData } from "node:worker_threads"; +import type { AppIntelSnapshot } from "@mobile-dev/shared/app-intelligence.ts"; +import { analyzeApp } from "./analyzer.ts"; + +type WorkerInput = { projectPath: string }; +type WorkerOutput = + | { type: "progress"; message: string } + | { type: "result"; snapshot: AppIntelSnapshot } + | { type: "error"; name: string; message: string }; + +if (!parentPort) throw new Error("App Intelligence worker requires a parent port."); + +const port = parentPort; +const { projectPath } = workerData as WorkerInput; + +try { + const snapshot = await analyzeApp(projectPath, { + onProgress: message => port.postMessage({ type: "progress", message } satisfies WorkerOutput), + }); + port.postMessage({ type: "result", snapshot } satisfies WorkerOutput); +} catch (error) { + port.postMessage({ + type: "error", + name: error instanceof Error ? error.name : "Error", + message: error instanceof Error ? error.message : String(error), + } satisfies WorkerOutput); +} diff --git a/packages/core/src/panel-tools.ts b/packages/core/src/panel-tools.ts index 578d4108..17707a4e 100644 --- a/packages/core/src/panel-tools.ts +++ b/packages/core/src/panel-tools.ts @@ -70,7 +70,7 @@ function guarded(handler: (input: T, context: ServerRequestContext) => Promis }; } -const INSTRUCTIONS = "For mobile app development, open mobile_open_simulator before the first device launch, or reuse the open view. Use selected device IDs. Control apps with the device_* tools when the server has them. When the user asks to see their devices, open or reuse the view, whose device menu lists them. If a task has ambiguous device targets, ask the user with mobile_choose_devices when it is available, otherwise in chat; proceed only after they choose. 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, when installed, for device control, logs, profiles, and performance workflows."; +const INSTRUCTIONS = "For mobile app development, open mobile_open_simulator before the first device launch, or reuse the open view. Use selected device IDs. Control apps with the device_* tools when the server has them. Use mobile_generate_app_intel to create the app's screen/dependency map, then mobile_open_app_intel to show it in chat. For a specific screen's dependencies, look its ID up with mobile_app_intel and pass it as mobile_open_app_intel's appIntelScreen to render that screen's graph; use mobile_app_intel alone, without opening the panel, for a file's importers/imports or when the user wants the answer as text, not a view. When the user asks to see their devices, open or reuse the view, whose device menu lists them. If a task has ambiguous device targets, ask the user with mobile_choose_devices when it is available, otherwise in chat; proceed only after they choose. 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, when installed, for device control, logs, profiles, App Intelligence, and performance workflows."; /** Mobile Dev's own device views, which a host adds to the server it runs (see createPanel). */ export type Panel = { @@ -217,9 +217,10 @@ function registerPanel(server: McpServer, { android, cpu, fps, drivers, page, sh } }); - const openPanel = (tool: string) => guarded(async (panelTarget: PanelTarget) => { - let status; - try { status = await baguette.start(); } + const openPanel = (tool: string, defaults: Partial = {}, startBackend = true) => guarded(async (input: PanelTarget) => { + const panelTarget = { ...input, ...defaults }; + let status = { connected: false, managed: false, baseUrl: "", devices: [] as Awaited>["devices"], error: undefined as string | undefined }; + if (startBackend) try { status = { ...(await baguette.start()), error: undefined }; } catch (error) { // The start already waited on the device list, so don't wait its full timeout again. const fallback = await baguette.status(AbortSignal.timeout(2000)); @@ -228,20 +229,20 @@ function registerPanel(server: McpServer, { android, cpu, fps, drivers, page, sh // Hosts can pass the model either form of a result, and Claude Code passes only the // structured one, so another place to show the panel goes into both. const opening = await opened({ tool, target: panelTarget }); - const response = result({ ...status, panelTarget, ...opening ? { browserPanel: opening } : {} }, status.connected ? `Found ${status.devices.length} simulators.` : status.error ?? "The bundled simulator backend could not start."); + const response = result({ ...status, panelTarget, ...opening ? { browserPanel: opening } : {} }, startBackend ? (status.connected ? `Found ${status.devices.length} simulators.` : status.error ?? "The bundled simulator backend could not start.") : "Opened App Intelligence."); if (opening) response.content.push({ type: "text", text: opening.instructions }); return response; }); // Inline, the host renders each opening tool's page in the chat; otherwise the tool only // names it, and a host feature such as the browser panel shows it. - const openingTool = (name: string, config: { description: string; resourceUri: string; entrypoint: "global" | "thread" }) => { + const openingTool = (name: string, config: { description: string; resourceUri: string; entrypoint: "global" | "thread"; defaults?: Partial; startBackend?: boolean }) => { const definition = { title: "Mobile Dev", description: config.description, inputSchema: panelTargetSchema, outputSchema: { ...statusOutput, panelTarget: panelTargetSchema, browserPanel: browserPanelOutput }, annotations: write, }; if (inline === false) { - server.registerTool(name, { ...definition, _meta: { [PANEL_PAGE_META]: { resourceUri: config.resourceUri } } }, openPanel(name)); + server.registerTool(name, { ...definition, _meta: { [PANEL_PAGE_META]: { resourceUri: config.resourceUri } } }, openPanel(name, config.defaults, config.startBackend)); return; } registerAppTool(server, name, { @@ -250,7 +251,7 @@ function registerPanel(server: McpServer, { android, cpu, fps, drivers, page, sh ui: { resourceUri: config.resourceUri, visibility: ["app", "model"] }, "openai/ui": { entrypoints: [{ type: config.entrypoint }] }, }, - }, openPanel(name)); + }, openPanel(name, config.defaults, config.startBackend)); }; openingTool("mobile_open_workspace", { @@ -263,6 +264,11 @@ function registerPanel(server: McpServer, { android, cpu, fps, drivers, page, sh resourceUri: APP_URI, entrypoint: "thread", }); + openingTool("mobile_open_app_intel", { + description: "Open App Intelligence when the user asks to see or visualize the selected React Native app's screen and dependency graph, or a specific screen's dependencies. Pass appIntelScreen with a screen ID from mobile_app_intel (use operation=search first if only given a name) to focus the graph on that screen. This does not launch a device or generate a new snapshot.", + resourceUri: WORKSPACE_URI, entrypoint: "global", defaults: { workspaceTool: "app-intel" }, startBackend: false, + }); + server.registerTool("mobile_list_simulators", { title: "List iOS simulators", description: "Start the plugin's bundled backend if needed and list available and booted simulators.", inputSchema: {}, outputSchema: statusOutput, annotations: write, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index b6e88280..f8f678ec 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -40,9 +40,11 @@ import { registerDisplayFpsTools } from "./fps/tools.ts"; import { registerEarlierReleases } from "./earlier-releases.ts"; import { Simctl } from "./simctl.ts"; import type { Panel } from "./panel-tools.ts"; +import { AppIntelligenceService } from "./app-intelligence/service.ts"; +import { registerAppIntelligenceTools } from "./app-intelligence/tools.ts"; // Servers without the panel serve hosts that show devices themselves, when they can. -const INSTRUCTIONS = "For mobile app development, read logs, record CPU, memory and FPS, and inspect app storage with these tools; control devices with the device_* tools when the server has them. Mobile Dev shows no device view here: when the user asks to open or see a simulator or emulator, use the host's own simulator view if it has one, such as Claude Code's iOS Simulator or Android Emulator. Use device IDs from tool results; never invent them. If a task has ambiguous device targets, ask the user with your own question tool when you have one, otherwise in chat; proceed only after they choose. Show recordings with mobile_render_performance_recording and comparisons with mobile_compare_performance_recordings. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings. See the Mobile Dev skill, when installed, for logs, profiles, and performance workflows."; +const INSTRUCTIONS = "For mobile app development, read logs, record CPU, memory and FPS, inspect app storage, and query App Intelligence with these tools; control devices with the device_* tools when the server has them. Use mobile_open_app_intel when the user asks to see the app's screen/dependency map, mobile_generate_app_intel to create it, and mobile_app_intel for screen or file dependency questions. Mobile Dev shows no device view here: when the user asks to open or see a simulator or emulator, use the host's own simulator view if it has one, such as Claude Code's iOS Simulator or Android Emulator. Use device IDs from tool results; never invent them. If a task has ambiguous device targets, ask the user with your own question tool when you have one, otherwise in chat; proceed only after they choose. Show recordings with mobile_render_performance_recording and comparisons with mobile_compare_performance_recordings. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings. See the Mobile Dev skill, when installed, for logs, profiles, App Intelligence, and performance workflows."; /** Gives a served UI page the server's telemetry environment, opt-out and anonymous identity. */ export function configureUI(content: string): string { @@ -80,7 +82,7 @@ export async function createPlugin(html: string | (() => Promise), o }); instrumentMcpServer(server); const analytics = instrumentProductAnalytics(server); - addToolIcons(server, { mobile_open_workspace: PHONE_ICONS, mobile_open_simulator: PHONE_ICONS, mobile_choose_devices: PHONE_ICONS }); + addToolIcons(server, { mobile_open_workspace: PHONE_ICONS, mobile_open_simulator: PHONE_ICONS, mobile_open_app_intel: PHONE_ICONS, mobile_choose_devices: PHONE_ICONS }); const extensions = new OpenAIExtensions(server); server.server.oninitialized = () => { const clientName = server.server.getClientVersion()?.name; @@ -107,6 +109,8 @@ export async function createPlugin(html: string | (() => Promise), o registerDisplayFpsTools(server, fps); registerAppControlTools(server, drivers); registerAppStorageTools(server); + const appIntelligence = new AppIntelligenceService(); + registerAppIntelligenceTools(server, appIntelligence); const appFlow = await createAppFlowHost(); const closeAppFlow = registerAppFlowTools(server, appFlow, new AppFlowProjectChoices(APP_FLOW_DIRECTORY)); diff --git a/packages/core/src/profile-picker.ts b/packages/core/src/profile-picker.ts index 62d8bb26..b1e6c163 100644 --- a/packages/core/src/profile-picker.ts +++ b/packages/core/src/profile-picker.ts @@ -18,7 +18,7 @@ export type PickedProfile = Picked; const APPLESCRIPT = `set chosen to choose file with prompt "Choose a CPU or React profile" of type {"public.json", "json"} POSIX path of chosen`; -const APPLESCRIPT_FOLDER = `set chosen to choose folder with prompt "Choose the project this profile belongs to" +const APPLESCRIPT_FOLDER = `set chosen to choose folder with prompt "Choose a project folder" POSIX path of chosen`; const POWERSHELL = `Add-Type -AssemblyName System.Windows.Forms @@ -29,7 +29,7 @@ if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Ou const POWERSHELL_FOLDER = `Add-Type -AssemblyName System.Windows.Forms $dialog = New-Object System.Windows.Forms.FolderBrowserDialog -$dialog.Description = 'Choose the project this profile belongs to' +$dialog.Description = 'Choose a project folder' if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $dialog.SelectedPath }`; function command(): { file: string; args: string[] } { @@ -45,7 +45,7 @@ function folderCommand(): { file: string; args: string[] } { switch (platform()) { case "darwin": return { file: "osascript", args: ["-e", APPLESCRIPT_FOLDER] }; case "win32": return { file: "powershell", args: ["-NoProfile", "-STA", "-Command", POWERSHELL_FOLDER] }; - default: return { file: "zenity", args: ["--file-selection", "--directory", "--title=Choose the project this profile belongs to"] }; + default: return { file: "zenity", args: ["--file-selection", "--directory", "--title=Choose a project folder"] }; } } diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts index 6886f723..a2ce4e3d 100644 --- a/packages/core/src/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -61,6 +61,13 @@ export function recordAppStorageOperation(operation: AppStorageOperation, platfo Sentry.metrics.gauge("app_storage.operation.duration", duration, { unit: "millisecond", attributes }); } +export function recordAppIntelOperation(operation: "generate" | "query" | "snapshot" | "pick" | "clear", outcome: "success" | "failed" | "cancelled", duration: number) { + if (process.env.MOBILE_DEV_TELEMETRY === "off") return; + const attributes = { component: "server", surface: "app-intel", operation, outcome }; + Sentry.metrics.count("app_intel.operation.samples", 1, { attributes }); + Sentry.metrics.gauge("app_intel.operation.duration", duration, { unit: "millisecond", attributes }); +} + /** * Time the App Flow project dialog stayed open. Like the sweep's decision wait, this is a person * deciding rather than plugin work, so it is reported on its own and never folded into a UI timing. @@ -307,7 +314,7 @@ const resourceKinds: Record = { "android-stream:": "android_video", "ios-video:": "ios_video", "ui:": "ui", }; const allowedContext: Record = { - surface: ["app-flow", "logs", "performance", "storage", "simulator", "recording", "comparison", "tracesift"], view: ["panel", "workspace", "recording", "comparison", "tracesift"], layout: ["ios", "android", "both", "none"], + surface: ["app-flow", "logs", "performance", "storage", "simulator", "recording", "comparison", "tracesift", "app-intel"], view: ["panel", "workspace", "recording", "comparison", "tracesift"], layout: ["ios", "android", "both", "none"], device_platform: ["ios", "android", "mixed"], device_kind: ["physical", "simulator", "emulator", "none"], host: TELEMETRY_HOSTS, }; diff --git a/packages/core/test/app-intelligence.test.ts b/packages/core/test/app-intelligence.test.ts new file mode 100644 index 00000000..1b8f899a --- /dev/null +++ b/packages/core/test/app-intelligence.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { Worker } from "node:worker_threads"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { analyzeApp } from "../src/app-intelligence/analyzer.ts"; +import { lookupAppIntel } from "../src/app-intelligence/lookup.ts"; + +async function runWorker(projectPath: string) { + return await new Promise((resolve, reject) => { + const worker = new Worker(new URL("../src/app-intelligence/worker.ts", import.meta.url), { workerData: { projectPath } }); + worker.on("message", message => { + if (message.type === "result") resolve(message.snapshot); + else if (message.type === "error") reject(new Error(message.message)); + }); + worker.on("error", reject); + }); +} + +test("App Intelligence discovers Expo Router and React Navigation without App Flow", async () => { + const root = await mkdtemp(join(tmpdir(), "mobile-dev-app-intel-")); + try { + await mkdir(join(root, "app"), { recursive: true }); + await mkdir(join(root, "src"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "fixture" })); + await writeFile(join(root, "app", "index.tsx"), "import { nativeValue } from '../src/native-value'; export default function Home(){ return nativeValue }\n"); + await writeFile(join(root, "app", "_layout.tsx"), "import { Stack } from 'expo-router'; export default () => \n"); + await writeFile(join(root, "src", "native-value.ios.ts"), "export const nativeValue = 'ios'\n"); + await writeFile(join(root, "src", "native-value.android.ts"), "export const nativeValue = 'android'\n"); + await writeFile(join(root, "src", "Details.tsx"), "export default function Details(){ return null }\n"); + await writeFile(join(root, "src", "Navigator.tsx"), "import Details from './Details'; export default () => \n"); + + const snapshot = await analyzeApp(root); + assert.equal(snapshot.screens["expo:index"].route, "/"); + assert.equal(snapshot.screens["navigation:Details"].source, "src/Details.tsx"); + const platformEdges = snapshot.edges.filter(edge => edge.from === "file:app/index.tsx" && edge.kind === "import"); + assert.deepEqual(platformEdges.map(edge => [edge.to, edge.platforms]).sort(), [ + ["file:src/native-value.android.ts", ["android"]], + ["file:src/native-value.ios.ts", ["ios"]], + ]); + const result = lookupAppIntel(snapshot, { operation: "screen", query: "Details", direction: "outgoing", depth: 1, offset: 0, limit: 30 }); + assert.ok(result.items.some(item => item.kind === "file" && item.path === "src/Details.tsx")); + const home = lookupAppIntel(snapshot, { operation: "screen", query: "Home", direction: "outgoing", depth: 1, offset: 0, limit: 30 }); + assert.ok(home.items.some(item => item.kind === "file" && item.path === "src/native-value.ios.ts")); + assert.ok(home.items.some(item => item.kind === "file" && item.path === "src/native-value.android.ts")); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("App Intelligence analysis runs in its cancellable worker entry", async () => { + const root = await mkdtemp(join(tmpdir(), "mobile-dev-app-intel-worker-")); + try { + await mkdir(join(root, "app"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "worker-fixture" })); + await writeFile(join(root, "app", "index.tsx"), "export default function Home(){ return null }\n"); + const snapshot = await runWorker(root); + assert.equal(snapshot.screens["expo:index"].route, "/"); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("App Intelligence counts how many screens reach each file", async () => { + const root = await mkdtemp(join(tmpdir(), "mobile-dev-app-intel-reach-")); + try { + await mkdir(join(root, "app"), { recursive: true }); + await mkdir(join(root, "src"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "reach-fixture" })); + await writeFile(join(root, "app", "_layout.tsx"), "import './../src/theme'; export default () => null\n"); + await writeFile(join(root, "app", "index.tsx"), "import { shared } from '../src/shared'; export default function Home(){ return shared }\n"); + await writeFile(join(root, "app", "profile.tsx"), "import { shared } from '../src/shared'; import { only } from '../src/only'; export default function Profile(){ return shared + only }\n"); + // shared <-> cycle makes sure the walk terminates and still counts each screen once. + await writeFile(join(root, "src", "shared.ts"), "import { deep } from './deep'; export const shared = deep\n"); + await writeFile(join(root, "src", "deep.ts"), "import { shared } from './shared'; export const deep = shared\n"); + await writeFile(join(root, "src", "only.ts"), "export const only = 1\n"); + await writeFile(join(root, "src", "theme.ts"), "export const theme = 1\n"); + await writeFile(join(root, "src", "orphan.ts"), "export const orphan = 1\n"); + + const snapshot = await analyzeApp(root); + assert.equal(snapshot.files["src/shared.ts"].reach, 2); + assert.equal(snapshot.files["src/deep.ts"].reach, 2); + assert.equal(snapshot.files["src/only.ts"].reach, 1); + assert.equal(snapshot.files["src/theme.ts"].reach, 2, "a shared layout reaches every screen under it"); + assert.equal(snapshot.files["src/orphan.ts"].reach, 0); + assert.equal(snapshot.files["app/index.tsx"].reach, 1, "a screen reaches its own source"); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("App Intelligence reads JSONC path aliases and gives each screen its own component", async () => { + const root = await mkdtemp(join(tmpdir(), "mobile-dev-app-intel-alias-")); + try { + await mkdir(join(root, "src", "screens"), { recursive: true }); + await mkdir(join(root, "node_modules", "@react-native", "typescript-config"), { recursive: true }); + await mkdir(join(root, "node_modules", "react-native-screens", "lib"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "alias-fixture" })); + // Comments and trailing commas are legal in tsconfig.json, and the aliases live one `extends` up. + await writeFile(join(root, "node_modules", "@react-native", "typescript-config", "tsconfig.json"), + '{\n "compilerOptions": {\n // the app aliases its own source\n "baseUrl": "../../..",\n "paths": {"#/*": ["./src/*"], "@repeat/*": ["./src/*/nested/*"],},\n },\n}\n'); + await writeFile(join(root, "tsconfig.json"), '{\n /* inherited */\n "extends": "@react-native/typescript-config/tsconfig.json",\n}\n'); + await writeFile(join(root, "node_modules", "react-native-screens", "package.json"), JSON.stringify({ name: "react-native-screens", main: "lib/index.js" })); + await writeFile(join(root, "node_modules", "react-native-screens", "lib", "index.js"), "require('./deep'); module.exports = {}\n"); + await writeFile(join(root, "node_modules", "react-native-screens", "lib", "deep.js"), "module.exports = {}\n"); + await writeFile(join(root, "src", "screens", "Settings.tsx"), "import { helper } from '#/util/helper'; export function SettingsScreen(){ return helper }\n"); + await writeFile(join(root, "src", "screens", "Profile.tsx"), "export function ProfileScreen(){ return null }\n"); + await mkdir(join(root, "src", "util"), { recursive: true }); + await mkdir(join(root, "src", "shared", "nested"), { recursive: true }); + await writeFile(join(root, "src", "util", "helper.ts"), "export const helper = 1\n"); + await writeFile(join(root, "src", "shared", "nested", "shared.ts"), "export const repeated = 1\n"); + await writeFile(join(root, "src", "Navigation.tsx"), [ + "import 'react-native-screens'", + "import { repeated } from '@repeat/shared'", + "import { SettingsScreen } from '#/screens/Settings'", + "function TabsNavigator(){ return repeated }", + "export default () => <> SettingsScreen} />", + " require('#/screens/Profile').ProfileScreen} />", + " TabsNavigator} />", + ].join("\n") + "\n"); + + const snapshot = await analyzeApp(root); + assert.equal(snapshot.screens["navigation:Settings"].source, "src/screens/Settings.tsx", "getComponent names the screen's own file"); + assert.equal(snapshot.screens["navigation:Profile"].source, "src/screens/Profile.tsx", "a require() inside getComponent resolves too"); + assert.equal(snapshot.screens["navigation:Tabs"].source, "src/Navigation.tsx", "a navigator declared in place belongs to its own file"); + assert.deepEqual(snapshot.diagnostics.filter(entry => entry.code === "unresolved-screen"), []); + assert.ok(!("#" in snapshot.packages), "an aliased '#/' import is app code, not a package named '#'"); + assert.equal(snapshot.files["src/util/helper.ts"].reach, 2, "the alias is followed into the app's own source, from Settings and from the navigator"); + assert.ok(snapshot.edges.some(edge => edge.from === "file:src/Navigation.tsx" && edge.to === "file:src/shared/nested/shared.ts"), "every wildcard in an alias target is substituted"); + // A dependency is one leaf: its entry file is recorded, its internals never enter the graph. + assert.equal(snapshot.packages["react-native-screens"].entry, "node_modules/react-native-screens/lib/index.js"); + assert.deepEqual(Object.keys(snapshot.files).filter(path => path.includes("node_modules/")), []); + assert.deepEqual(snapshot.edges.filter(edge => edge.to?.includes("node_modules/")), []); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("App Intelligence treats a workspace package behind a node_modules symlink as app code", async () => { + const root = await mkdtemp(join(tmpdir(), "mobile-dev-app-intel-workspace-")); + try { + await mkdir(join(root, "app"), { recursive: true }); + await mkdir(join(root, "modules", "ui", "src"), { recursive: true }); + await mkdir(join(root, "node_modules"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "workspace-fixture" })); + await writeFile(join(root, "modules", "ui", "package.json"), JSON.stringify({ name: "ui", main: "src/index.ts" })); + await writeFile(join(root, "modules", "ui", "src", "index.ts"), "export const button = 1\n"); + await symlink(join(root, "modules", "ui"), join(root, "node_modules", "ui"), "dir"); + await writeFile(join(root, "app", "index.tsx"), "import { button } from 'ui'; export default function Home(){ return button }\n"); + + const snapshot = await analyzeApp(root); + assert.ok(!("ui" in snapshot.packages), "a workspace package is this app's own code"); + assert.equal(snapshot.files["modules/ui/src/index.ts"].reach, 1); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("the project picker remembers recent projects, most recent first", async () => { + // Preferences live under the home directory, so the check runs against a throwaway one. + const home = await mkdtemp(join(tmpdir(), "mobile-dev-app-intel-home-")); + try { + const storage = new URL("../src/app-intelligence/storage.ts", import.meta.url).href; + const script = [ + `const { readPreferences, rememberProject } = await import(${JSON.stringify(storage)});`, + "await rememberProject('/one'); await rememberProject('/two'); await rememberProject('/one');", + "process.stdout.write(JSON.stringify(await readPreferences()));", + ].join("\n"); + const { stdout } = await promisify(execFile)(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", script], { env: { ...process.env, HOME: home } }); + assert.deepEqual(JSON.parse(stdout), { projectPath: "/one", recents: ["/one", "/two"] }); + } finally { await rm(home, { recursive: true, force: true }); } +}); diff --git a/packages/core/test/browser-panel.test.ts b/packages/core/test/browser-panel.test.ts index 25a705ea..3dd27c2b 100644 --- a/packages/core/test/browser-panel.test.ts +++ b/packages/core/test/browser-panel.test.ts @@ -142,8 +142,8 @@ test("opening the panel links the browser page only for hosts without MCP Apps", const host = new Client({ name: "claude-code", version: "1" }, { capabilities }); await plugin.server.connect(shared); await host.connect(hostSide); - for (const name of ["mobile_open_simulator", "mobile_open_workspace"]) { - const result = await host.callTool({ name, arguments: { platform: "android" } }); + for (const name of ["mobile_open_simulator", "mobile_open_workspace", "mobile_open_app_intel"]) { + const result = await host.callTool({ name, arguments: { platform: "android", ...(name === "mobile_open_app_intel" ? { appIntelScreen: "navigation:Profile" } : {}) } }); const texts = (result.content as Array<{ text: string }>).map(item => item.text); const structured = result.structuredContent as { browserPanel?: { url: string } }; if (ui) { assert.equal(texts.length, 1); assert.equal(structured.browserPanel, undefined); continue; } @@ -152,6 +152,7 @@ test("opening the panel links the browser page only for hosts without MCP Apps", const link = new URL(texts[1].match(/http:\/\/127\.0\.0\.1:\d+\/[\w-]+\/\?\S+/)![0]); assert.equal(link.searchParams.get("tool"), name); assert.equal(link.searchParams.get("platform"), "android"); + assert.equal(link.searchParams.get("appIntelScreen"), name === "mobile_open_app_intel" ? "navigation:Profile" : null); } await host.close(); await plugin.close(); @@ -175,7 +176,7 @@ test("a panel that never renders inline links the browser page for MCP Apps host await host.connect(hostSide); t.after(async () => { await host.close(); await plugin.close(); }); const { tools } = await host.listTools(); - for (const name of ["mobile_open_simulator", "mobile_open_workspace"]) { + for (const name of ["mobile_open_simulator", "mobile_open_workspace", "mobile_open_app_intel"]) { const tool = tools.find(candidate => candidate.name === name)!; // No UI resource for the host to render, and the page named for the browser panel. assert.equal(tool._meta?.ui, undefined); diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 5934dfb1..0d1ed246 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -23,7 +23,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -test("both opening tools accept optional device intent and preserve it in their results", async t => { +test("opening tools accept optional intent and preserve it in their results", async t => { const fake = await fakeBaguette(); const plugin = await createTestPlugin("", { panel: createPanel({ baguette: new Baguette(fake.url), simulatorInput: fakeSimulatorInput() }) }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -40,6 +40,10 @@ test("both opening tools accept optional device intent and preserve it in their const invalid = await client.callTool({ name, arguments: { platform: "android", kind: "simulator" } }); assert.equal(invalid.isError, true); } + const appIntel = await client.callTool({ name: "mobile_open_app_intel", arguments: {} }); + assert.equal(appIntel.isError, undefined); + assert.deepEqual(appIntel.structuredContent?.panelTarget, { workspaceTool: "app-intel" }); + assert.equal(appIntel.structuredContent?.connected, false); const boots = fake.requests.filter(request => request.path.endsWith("/boot")); assert.equal(boots.length, 0); }); @@ -241,7 +245,11 @@ test("MCP tools expose native entrypoints and complete the simulator workflow", const workspace = tools.tools.find(tool => tool.name === "mobile_open_workspace")!; const workspaceMetadata = OpenAIUiToolMetadataSchema.parse(workspace._meta?.["openai/ui"]); assert.deepEqual(workspaceMetadata.entrypoints?.map(item => item.type), ["global"]); - for (const tool of [open, workspace]) { + const appIntel = tools.tools.find(tool => tool.name === "mobile_open_app_intel")!; + const appIntelMetadata = OpenAIUiToolMetadataSchema.parse(appIntel._meta?.["openai/ui"]); + assert.deepEqual(appIntelMetadata.entrypoints?.map(item => item.type), ["global"]); + assert.deepEqual(tools.tools.find(tool => tool.name === "mobile_clear_app_intel")?.annotations, { readOnlyHint: false, destructiveHint: true, openWorldHint: false }); + for (const tool of [open, workspace, appIntel]) { // The desktop host rejects unknown openai/ui keys, unlike the SDK parser. assert.deepEqual(Object.keys(tool._meta?.["openai/ui"] as object), ["entrypoints"]); assert.equal(tool.icons?.[0].mimeType, "image/svg+xml"); diff --git a/packages/core/test/telemetry.test.ts b/packages/core/test/telemetry.test.ts index ae9ff7ef..ee71d253 100644 --- a/packages/core/test/telemetry.test.ts +++ b/packages/core/test/telemetry.test.ts @@ -477,6 +477,13 @@ test("UI trace context crosses the MCP bridge and handled server errors exclude assert.equal(observedTags?.surface, "tracesift"); assert.equal(observedTags?.view, "tracesift"); assert.equal(observedTags?.profilePath, undefined); + await client.callTool({ name: "test_action", arguments: { secret: "PRIVATE_APP_INTEL_QUERY" }, _meta: { + [TELEMETRY_META_KEY]: { surface: "app-intel", view: "workspace", projectPath: "/Users/alice/private-app", screen: "PRIVATE_SCREEN" }, + } }); + assert.equal(observedTags?.surface, "app-intel"); + assert.equal(observedTags?.view, "workspace"); + assert.equal(observedTags?.projectPath, undefined); + assert.equal(observedTags?.screen, undefined); // Every host shares this server, so a known host value survives the allowlist; anything else is dropped. await client.callTool({ name: "test_action", arguments: { secret: "PRIVATE_HOST_ARGUMENT" }, _meta: { [TELEMETRY_META_KEY]: { surface: "logs", view: "workspace", host: "claude" }, @@ -494,14 +501,15 @@ test("UI trace context crosses the MCP bridge and handled server errors exclude contains(encoded, "Handled tool failed"); const eventItems = envelopes.flatMap(envelope => envelope[1]); const errors = eventItems.filter(item => item[0].type === "event"); - // One handled failure per tool call above: logs, recording, comparison, App Flow, TraceSift and two host checks. - assert.equal(errors.length, 7); + // One handled failure per tool call above: logs, recording, comparison, App Flow, + // TraceSift, App Intelligence and two host checks. + assert.equal(errors.length, 8); const unavailable = new SimulatorUnavailableError("Expected stopped simulator"); captureServerError(unavailable, "expected"); await Sentry.flush(); const afterItems = envelopes.flatMap(envelope => envelope[1]); const afterErrors = afterItems.filter(item => item[0].type === "event"); - assert.equal(afterErrors.length, 7); + assert.equal(afterErrors.length, 8); }); test("error filters retain only generated identity while metrics and spans omit user dimensions", async () => { diff --git a/packages/mcp-app/src/browser-panel.ts b/packages/mcp-app/src/browser-panel.ts index 3b8c6847..73a55e8d 100644 --- a/packages/mcp-app/src/browser-panel.ts +++ b/packages/mcp-app/src/browser-panel.ts @@ -20,11 +20,11 @@ function notice(text: string) { async function open() { const query = new URLSearchParams(location.search); const toolName = query.get("tool") ?? "mobile_open_simulator"; - if (toolName !== "mobile_open_simulator" && toolName !== "mobile_open_workspace") { + if (toolName !== "mobile_open_simulator" && toolName !== "mobile_open_workspace" && toolName !== "mobile_open_app_intel") { throw new Error("This link does not open a Mobile Dev panel."); } const target: Record = {}; - for (const key of ["platform", "kind", "deviceId"]) { + for (const key of ["platform", "kind", "deviceId", "workspaceTool", "appIntelScreen"]) { const value = query.get(key); if (value !== null) target[key] = value; } diff --git a/packages/mcp-app/src/components/workspace.tsx b/packages/mcp-app/src/components/workspace.tsx index 46b0238e..ab061025 100644 --- a/packages/mcp-app/src/components/workspace.tsx +++ b/packages/mcp-app/src/components/workspace.tsx @@ -1,12 +1,13 @@ -import { ActivityIcon, DatabaseIcon, GitForkIcon, TerminalIcon, PanelsTopLeftIcon, ChartNoAxesCombinedIcon } from "lucide-react"; +import { ActivityIcon, DatabaseIcon, GitForkIcon, TerminalIcon, PanelsTopLeftIcon, ChartNoAxesCombinedIcon, NetworkIcon } from "lucide-react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import type { ReactNode } from "react"; -import type { WorkspaceTools } from "@mobile-dev/ui/workspace-tools.ts"; +import type { ToolName, WorkspaceTools } from "@mobile-dev/ui/workspace-tools.ts"; import { PerformanceView } from "@mobile-dev/ui/components/performance-view.tsx"; import { SimulatorView } from "@mobile-dev/ui/components/simulator-view.tsx"; import { LogsView } from "@mobile-dev/ui/components/logs-view.tsx"; import { StorageView } from "@mobile-dev/ui/components/storage-view.tsx"; import { TraceSiftView } from "@mobile-dev/ui/components/tracesift-view.tsx"; +import { AppIntelligenceView } from "@mobile-dev/ui/components/app-intelligence-view.tsx"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@mobile-dev/ui/components/ui/base-select.tsx"; import { Button } from "@mobile-dev/ui/components/ui/button.tsx"; @@ -26,14 +27,16 @@ const tools = { logs: { id: "tool-logs", label: "Logs", icon: TerminalIcon }, performance: { id: "tool-performance", label: "Performance", icon: ActivityIcon }, storage: { id: "tool-storage", label: "Storage", icon: DatabaseIcon }, + "app-intel": { id: "tool-app-intel", label: "App Intel", icon: NetworkIcon }, tracesift: { id: "tool-tracesift", label: "TraceSift", icon: ChartNoAxesCombinedIcon }, "app-flow": { id: "tool-app-flow", label: "App Flow", icon: GitForkIcon }, }; type Tool = keyof typeof tools; const isTool = (value: string): value is Tool => value in tools; -export function Workspace({ panels, recordingController, banner, displayModeControl, contextControl, automaticLayout, onLayout }: { panels: WorkspaceTools; recordingController: RecordingController; banner?: ReactNode; displayModeControl?: ReactNode; contextControl?: ReactNode; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) { +export function Workspace({ panels, recordingController, banner, displayModeControl, contextControl, automaticLayout, automaticTool, automaticToolRevision = 0, onLayout }: { panels: WorkspaceTools; recordingController: RecordingController; banner?: ReactNode; displayModeControl?: ReactNode; contextControl?: ReactNode; automaticLayout?: DeviceLayout; automaticTool?: ToolName; automaticToolRevision?: number; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) { const { logs, performance, storage, tracesift } = panels; + const appIntel = panels["app-intel"]; const appFlow = panels["app-flow"]; const [tool, setTool] = useState("logs"); const [savedVisible, setSavedVisible] = useState(false); @@ -63,9 +66,10 @@ export function Workspace({ panels, recordingController, banner, displayModeCont const logState = useSyncExternalStore(logs.subscribe, logs.getSnapshot); const storageState = useSyncExternalStore(storage.subscribe, storage.getSnapshot); const traceSiftState = useSyncExternalStore(tracesift.subscribe, tracesift.getSnapshot); + const appIntelState = useSyncExternalStore(appIntel.subscribe, appIntel.getSnapshot); const appFlowState = useSyncExternalStore(appFlow.subscribe, appFlow.getSnapshot); // A saved recording shows in the performance tool, so it counts as that tool being open. - const open = { logs: logState.open, performance: savedVisible || performanceState.open, storage: storageState.open, tracesift: traceSiftState.open, "app-flow": appFlowState.open }[tool]; + const open = { logs: logState.open, performance: savedVisible || performanceState.open, storage: storageState.open, "app-intel": appIntelState.open, tracesift: traceSiftState.open, "app-flow": appFlowState.open }[tool]; useEffect(() => { setUiSurface(open ? savedVisible ? "recording" : tool : "simulator"); }, [tool, open, savedVisible]); useEffect(() => { setUiTelemetryContext({ logs_open: logState.open, logs_paused: logState.paused, performance_running: performanceState.monitoring || performanceState.fpsMonitoring }); @@ -81,6 +85,7 @@ export function Workspace({ panels, recordingController, banner, displayModeCont setSavedVisible(false); panels.show(next); }; + useEffect(() => { if (automaticTool) { setTool(automaticTool); setSavedVisible(false); panels.show(automaticTool); } }, [automaticTool, automaticToolRevision]); const logsRef = usePanelRef(); const simulatorGroupRef = useGroupRef(); const workspaceGroupRef = useGroupRef(); @@ -121,7 +126,7 @@ export function Workspace({ panels, recordingController, banner, displayModeCont if (!previous) return; if (size.inPixels <= collapsedToolSize + 1 && isOpen()) closeTools(); else if (size.inPixels > 80 && isOpen() === false) showTool(tool); - }}>{tool === "logs" && }{tool === "storage" && }{tool === "tracesift" && }{tool === "app-flow" && open && }{tool === "performance" && open && (savedVisible ?
: )}; + }}>{tool === "logs" && }{tool === "storage" && }{tool === "app-intel" && }{tool === "tracesift" && }{tool === "app-flow" && open && }{tool === "performance" && open && (savedVisible ?
: )}; // The sidebar keeps these controls under its simulators. Fullscreen, or no visible // platform, moves them above the tools. const bottomToolbar = !fullscreen && layout !== "none"; diff --git a/packages/mcp-app/src/workspace-app.tsx b/packages/mcp-app/src/workspace-app.tsx index 1545ee66..e3924fb5 100644 --- a/packages/mcp-app/src/workspace-app.tsx +++ b/packages/mcp-app/src/workspace-app.tsx @@ -14,6 +14,7 @@ import type { DeviceLayout } from "@mobile-dev/ui/device-layout.ts"; import { panelTargetSchema } from "@mobile-dev/shared/panel-target.ts"; import { DeviceWorkspace } from "@mobile-dev/ui/device-workspace.ts"; import { WorkspaceTools } from "@mobile-dev/ui/workspace-tools.ts"; +import type { ToolName } from "@mobile-dev/ui/workspace-tools.ts"; import { startLiveReload } from "./live-reload.ts"; import { ErrorBoundary, captureUiError, startUiTelemetry, stopUiTelemetry } from "@mobile-dev/ui/telemetry.ts"; import { PLUGIN_VERSION } from "@mobile-dev/shared/version.ts"; @@ -32,9 +33,11 @@ export function startWorkspaceApp(options: WorkspaceOptions = {}) { const recordingController = new RecordingController(app, { attachments, workspace: true }); const extension = options.extension?.(app); const reactRoot = createRoot(document.getElementById("root")!); + let automaticTool: ToolName | undefined; + let automaticToolRevision = 0; function renderWorkspace(automaticLayout?: DeviceLayout) { const workspace = Mobile Dev could not render. Reopen the panel to try again.

}> - } contextControl={} automaticLayout={automaticLayout} onLayout={changeLayout} /> + } contextControl={} automaticLayout={automaticLayout} automaticTool={automaticTool} automaticToolRevision={automaticToolRevision} onLayout={changeLayout} />
; reactRoot.render(workspace); } @@ -112,6 +115,9 @@ export function startWorkspaceApp(options: WorkspaceOptions = {}) { connectionFailed(error); return; } + if (target.data.workspaceTool) { automaticTool = target.data.workspaceTool; automaticToolRevision++; renderWorkspace(); } + if (target.data.workspaceTool === "app-intel" && target.data.appIntelScreen) panels["app-intel"].selectScreen(target.data.appIntelScreen); + if (target.data.workspaceTool === "app-intel") return; void devices.open(target.data).catch(connectionFailed); } app.ontoolinput = input => { diff --git a/packages/shared/src/app-intelligence.ts b/packages/shared/src/app-intelligence.ts new file mode 100644 index 00000000..709a3996 --- /dev/null +++ b/packages/shared/src/app-intelligence.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +export const intelPlatforms = ["ios", "android"] as const; +export type IntelPlatform = typeof intelPlatforms[number]; +export type IntelRef = `screen:${string}` | `file:${string}` | `package:${string}`; + +export type IntelFile = { fingerprint: string; kind: "source" | "asset"; reach: number }; +export type IntelDiagnostic = { code: string; message: string; file?: string }; +export type IntelEdge = { + from: IntelRef; + to: IntelRef | null; + kind: "import" | "navigation" | "source" | "layout"; + specifier?: string; + platforms?: IntelPlatform[]; + dynamic?: boolean; + typeOnly?: boolean; +}; +export type IntelScreen = { title: string; route: string | null; source: string | null; layouts: string[] }; +export type AppIntelSnapshot = { + formatVersion: 1; + meta: { projectId: string; generation: string; analyzedAt: string; analyzerVersion: string; sourceFingerprint: string }; + files: Record; + /** Third-party dependencies, kept as leaves: `entry` is the file that resolved, not a subtree. */ + packages: Record; + screens: Record; + edges: IntelEdge[]; + diagnostics: IntelDiagnostic[]; + indexes: { screens: Record; incoming: Record; outgoing: Record }; +}; + +export type AppIntelStatus = { + projectPath: string; + run: { runId: string; phase: "discovering" | "analyzing" | "saving"; message: string } | null; + snapshot: Pick | null; +}; + +export const generateAppIntelInput = z.object({ projectPath: z.string().min(1).max(4096).optional() }).strict(); +export const appIntelQueryInput = z.object({ + operation: z.enum(["search", "screen", "file"]), + query: z.string().min(1).max(4096), + projectPath: z.string().min(1).max(4096).optional(), + direction: z.enum(["incoming", "outgoing", "both"]).default("incoming"), + depth: z.number().int().min(0).max(3).default(1), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().min(1).max(100).default(30), +}).strict(); +export type AppIntelQuery = z.infer; + +export const screenRef = (id: string): IntelRef => `screen:${id}`; +export const fileRef = (path: string): IntelRef => `file:${path}`; +export const packageRef = (name: string): IntelRef => `package:${name}`; + diff --git a/packages/shared/src/panel-target.ts b/packages/shared/src/panel-target.ts index 0acd009e..fb08cff5 100644 --- a/packages/shared/src/panel-target.ts +++ b/packages/shared/src/panel-target.ts @@ -3,8 +3,10 @@ import { z } from "zod"; const platform = z.enum(["ios", "android"]).optional().describe("The task's established platform. Omit when undecided."); const kind = z.enum(["simulator", "emulator", "physical"]).optional().describe("The requested device kind. Omit when undecided."); const deviceId = z.string().min(1).max(256).regex(/^[A-Za-z0-9_.:\[\]%-]+$/).optional().describe("An exact device ID from device discovery. Omit to select a matching device automatically."); +const workspaceTool = z.enum(["logs", "performance", "storage", "app-intel", "tracesift"]).optional().describe("A workspace tool to open. Omit for the device view."); +const appIntelScreen = z.string().min(1).max(512).optional().describe("An App Intelligence screen ID to focus when workspaceTool is app-intel, from mobile_app_intel's search or screen result. Omit to open on whatever screen the panel last showed."); -export const panelTargetSchema = z.object({ platform, kind, deviceId }).strict().superRefine((target, context) => { +export const panelTargetSchema = z.object({ platform, kind, deviceId, workspaceTool, appIntelScreen }).strict().superRefine((target, context) => { if (target.platform === "ios" && target.kind === "emulator" || target.platform === "android" && target.kind === "simulator") { context.addIssue({ code: "custom", path: ["kind"], message: "Use simulator for iOS or emulator for Android." }); } diff --git a/packages/shared/src/telemetry.ts b/packages/shared/src/telemetry.ts index 5a9f1842..4eaeed1f 100644 --- a/packages/shared/src/telemetry.ts +++ b/packages/shared/src/telemetry.ts @@ -41,7 +41,7 @@ export function validateTelemetryEnvironment(value: unknown): TelemetryEnvironme throw new Error("Sentry environment must be development or release."); } -export type Surface = "app-flow" | "logs" | "performance" | "storage" | "simulator" | "recording" | "comparison" | "tracesift"; +export type Surface = "app-flow" | "logs" | "performance" | "storage" | "simulator" | "recording" | "comparison" | "tracesift" | "app-intel"; export type TelemetryAttributes = Record; type Options = NonNullable[0]>; type ErrorEvent = Parameters>[0]; diff --git a/packages/skills/mobile-dev/SKILL.md b/packages/skills/mobile-dev/SKILL.md index 7afe0b9c..15e47733 100644 --- a/packages/skills/mobile-dev/SKILL.md +++ b/packages/skills/mobile-dev/SKILL.md @@ -36,6 +36,10 @@ device tools. 4. Build the app with the app project's own tools and the chosen device ID; the project's run command may also install and launch it. To restart an already installed app, use `mobile_launch_app` instead of rebuilding. Follow the project's framework skills where available. Before starting an app dev server, check for an existing server from that project and reuse it. Keep the same device for the panel, build target, and agent control; read the panel's shared IDs before acting and follow later user selections. Leave the panel and app available while continuing app work. Do not launch the app when the user asks only for compilation. 5. Control the app with the `device_*` tools, the bundled agent-device runtime's commands, and follow [the agent-device skill](../agent-device/SKILL.md) to drive it. In short: open the panel's device with `device_open` and a named session, act on refs from the latest snapshot with `settle: true`, verify the requested result with a named expectation, and close the session without `shutdown` so the device keeps running in the panel. Boot, launch, and app settings stay with the `mobile_*` tools above and below. +### App Intelligence + +App Intelligence statically discovers Expo Router and React Navigation screens and combines their source dependencies for iOS and Android; it does not require App Flow or a running device. When the user asks to see the app map, call `mobile_open_app_intel`. Generate or refresh it with `mobile_generate_app_intel`, passing `projectPath` when the panel has not remembered the intended project. For questions such as “what are the dependencies of Screen A?”, call `mobile_app_intel` with `operation: "screen"`, the exact screen ID or title, `direction: "outgoing"`, and depth 1 first. Use `search` only when the exact screen or project-relative file is unknown, and paginate only when `nextOffset` is present. + ### App control These tools work on iOS simulators and Android devices with `{ platform, deviceId }`, not on physical iPhones. They never build. diff --git a/packages/ui/src/app-intelligence-layout.ts b/packages/ui/src/app-intelligence-layout.ts new file mode 100644 index 00000000..1ab54594 --- /dev/null +++ b/packages/ui/src/app-intelligence-layout.ts @@ -0,0 +1,333 @@ +import type { AppIntelSnapshot, IntelPlatform, IntelRef } from "@mobile-dev/shared/app-intelligence.ts"; + +/** + * Pure geometry and aggregation for the App Intelligence Flow. Nothing here touches the DOM: + * the panel measures once and passes width and height in, so every layout stays testable. + */ + +export type NodePlatforms = "ios" | "android" | "both"; +export type Rect = { x: number; y: number; width: number; height: number }; + +const PACKAGE_PREFIX = "node_modules/"; + +/** A file or package is platform-specific when every edge that reaches it names one platform. */ +export function platformsFor(snapshot: AppIntelSnapshot, ref: IntelRef): NodePlatforms { + const seen = new Set(); + for (const index of snapshot.indexes.incoming[ref] ?? []) for (const platform of snapshot.edges[index].platforms ?? []) seen.add(platform); + if (seen.size === 1) return seen.has("ios") ? "ios" : "android"; + return "both"; +} + +export function reachOf(snapshot: AppIntelSnapshot, ref: IntelRef): number { + if (ref.startsWith("file:")) return snapshot.files[ref.slice(5)]?.reach ?? 0; + if (ref.startsWith("package:")) return snapshot.packages[ref.slice(8)]?.reach ?? 0; + return 0; +} + +const INDEX_FILE = /^index\.[cm]?[jt]sx?$/; + +/** + * What to call a node. `index.tsx` names nothing, so a barrel file is named for the folder it + * indexes: `src/components/forms/Toggle/index.tsx` reads as "Toggle", the way the import does. + */ +export function labelOf(ref: IntelRef): string { + const path = ref.replace(/^(?:file|package|screen):/, ""); + if (ref.startsWith("package:")) return path; + const parts = path.split("/"); + const name = parts.pop() ?? path; + return INDEX_FILE.test(name) && parts.length ? parts[parts.length - 1] : name; +} + +/** Where that node lives, in full: several `index.tsx` differ by nothing else. */ +export function pathOf(ref: IntelRef): string { + if (ref.startsWith("package:")) return PACKAGE_PREFIX; + const parts = ref.replace(/^(?:file|screen):/, "").split("/"); + const name = parts.pop() ?? ""; + if (INDEX_FILE.test(name) && parts.length) parts.pop(); + return parts.join("/"); +} + +/** Truncates a path from the left: the tail is what tells two of them apart. */ +export function ellipsize(text: string, max: number): string { + return text.length <= max ? text : `…${text.slice(text.length - max + 1)}`; +} + +/** Truncates a name from the right, where the distinguishing part comes first. */ +export function clampName(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; +} + +function channels(value: string): [number, number, number] { + return [parseInt(value.slice(1, 3), 16), parseInt(value.slice(3, 5), 16), parseInt(value.slice(5, 7), 16)]; +} + +/** + * Nesting tints. One muted card for every hop read as a grey ladder; one hue per depth makes the + * hop a node sits at legible at a glance, in either theme. + */ +const DEPTH_HUES = ["#6366f1", "#0ea5e9", "#10b981", "#f59e0b", "#ec4899"] as const; + +export const depthHue = (depth: number): string => DEPTH_HUES[Math.min(depth, DEPTH_HUES.length - 1)]; + +/** Translucent so the card tint sits on the panel background rather than fighting the theme. */ +export function depthTint(depth: number, alpha: number): string { + const [red, green, blue] = channels(depthHue(depth)); + return `rgba(${red},${green},${blue},${alpha})`; +} + +/** + * Architectural boundaries, derived from the path because the analyzer records files, not roles. + * Which ones exist is a property of the app being mapped, so the Flow filter is built from the + * boundaries actually present in a graph rather than from this list. + */ +export const boundaries = ["entry", "navigation", "hook", "component", "state", "api", "model", "native", "asset", "util", "package", "other"] as const; +export type Boundary = typeof boundaries[number]; + +/** "entry" rather than "screen": the analyzer's roots include lexicons and routes that render nothing. */ +export const boundaryLabel: Record = { + entry: "Entry points", navigation: "Navigation", hook: "Hooks", component: "Components", state: "State", + api: "API & data", model: "Types & schemas", native: "Native", asset: "Assets", util: "Utilities", + package: "Packages", other: "Other source", +}; + +const BOUNDARY_COLOR: Record = { + entry: "#8b5cf6", navigation: "#6366f1", hook: "#0ea5e9", component: "#14b8a6", state: "#f97316", + api: "#ef4444", model: "#a855f7", native: "#eab308", asset: "#84cc16", util: "#64748b", + package: "#94a3b8", other: "#78839b", +}; + +export const boundaryColor = (boundary: Boundary): string => BOUNDARY_COLOR[boundary]; + +const ASSET_EXTENSIONS = /\.(?:png|jpe?g|gif|webp|svg|avif|ttf|otf|woff2?|mp[34]|wav|lottie)$/i; +const NATIVE_EXTENSIONS = /\.(?:swift|kt|java|m|mm|h|hpp|cpp|cc|rs|podspec|gradle)$/i; + +/** Folder names that name a role, singular. The innermost folder that names one wins. */ +const FOLDER_ROLES: ReadonlyArray = [ + ["hook", ["hook"]], + ["navigation", ["navigation", "navigator", "router", "route", "routing"]], + ["api", ["api", "service", "client", "network", "query", "mutation", "graphql", "endpoint", "request", "repository", "fetcher"]], + ["state", ["store", "state", "redux", "context", "atom", "reducer", "slice", "provider"]], + ["model", ["type", "model", "schema", "entity", "dto", "lexicon", "def", "generated", "proto", "graphql-generated"]], + ["entry", ["screen", "page", "scene", "view"]], + ["component", ["component", "ui", "widget", "element", "layout", "primitive"]], + ["asset", ["asset", "image", "img", "font", "media", "icon"]], + ["util", ["util", "helper", "lib", "shared", "common"]], +]; + +const folderRole = new Map(); +for (const [boundary, words] of FOLDER_ROLES) for (const word of words) if (!folderRole.has(word)) folderRole.set(word, boundary); + +/** "queries" → "query", "hooks" → "hook": enough plural handling for folder names. */ +function singular(folder: string): string { + if (folder.endsWith("ies")) return folder.slice(0, -3) + "y"; + if (folder.endsWith("ses") || folder.endsWith("xes")) return folder.slice(0, -2); + return folder.endsWith("s") ? folder.slice(0, -1) : folder; +} + +const FILE_ROLES: ReadonlyArray = [ + ["api", /^(?:api|client|service|queries|mutations|endpoints|fetch|request|graphql)$/i], + ["state", /^(?:store|state|context|atoms?|reducers?|slices?)$/i], + ["navigation", /^(?:navigation|navigator|router|routes)$/i], +]; + +/** Classifies one node by its path. Deliberately ordered: the first rule that matches wins. */ +export function boundaryOf(ref: IntelRef): Boundary { + if (ref.startsWith("screen:")) return "entry"; + if (ref.startsWith("package:")) return "package"; + const path = ref.slice(5); + // A dependency inside node_modules is a package however the analyzer recorded it, and its own + // folder names describe its author's layout, not this app's, so no other rule may read them. + if (path === "node_modules" || path.includes("node_modules/")) return "package"; + const name = path.split("/").pop() ?? path; + const base = name.replace(/\.[^.]+$/, "").replace(/\.(?:ios|android|native|web)$/i, ""); + if (NATIVE_EXTENSIONS.test(name)) return "native"; + if (ASSET_EXTENSIONS.test(name)) return "asset"; + if (/^use[A-Z]/.test(base)) return "hook"; + if (name.endsWith(".d.ts")) return "model"; + const folders = path.toLowerCase().split("/").slice(0, -1).reverse(); + for (const folder of folders) { + const role = folderRole.get(singular(folder)); + if (role) return role; + } + for (const [boundary, pattern] of FILE_ROLES) if (pattern.test(base)) return boundary; + return /^[A-Z]/.test(base) ? "component" : "other"; +} + +/** The boundaries a set of nodes falls into, in display order, with how many nodes each holds. */ +export function boundariesIn(values: Iterable): Array<{ boundary: Boundary; count: number }> { + const counts = new Map(); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); + return boundaries.filter(boundary => counts.has(boundary)).map(boundary => ({ boundary, count: counts.get(boundary) as number })); +} + +// ── The Flow graph ──────────────────────────────────────────────────────────────────────────── + +/** Import hops drawn after the root. */ +export const FLOW_DEPTH = 3; +/** Rows a boundary shows before the rest fold into one "+N more" that opens them. */ +export const GROUP_CAP = 6; + +export type FlowGroup = { + key: string; boundary: Boundary; refs: IntelRef[]; count: number; hidden: number; collapsed: boolean; + /** The cards at the hop above that import into this boundary — what its connectors come from. */ + sources: IntelRef[]; +}; +export type FlowGraph = { start: IntelRef; levels: FlowGroup[][]; parent: Map; total: number }; +export type FlowOptions = { depth?: number; groupCap?: number; toggled?: ReadonlySet; expanded?: ReadonlySet }; + +/** One key per boundary per hop, because the same boundary recurs at every depth. */ +export const groupKey = (depth: number, boundary: Boundary) => `${depth}:${boundary}`; + +/** + * A whole app's imports three hops deep is thousands of cards and a thicket of links, so a hop + * opens only its first boundary in display order and names the rest. That leaves one readable + * spine down the graph to drill along, rather than a wall of cards or a row of bare headers. + * `toggled` holds the keys flipped away from this default, in either direction, so one set + * covers both folding an open boundary and opening a folded one. + */ +export const openByDefault = (depth: number, index: number) => depth <= 1 || index === 0; + +/** + * Breadth-first layering of one screen's dependencies, one level per import hop, with each level + * split into the boundaries its nodes fall into. A boundary is the unit the reader folds away: + * collapsing one drops its rows and everything they would have pulled in at the next hop. + */ +export function buildFlow(snapshot: AppIntelSnapshot, start: IntelRef, options: FlowOptions = {}): FlowGraph { + const limit = options.depth ?? FLOW_DEPTH; + const cap = options.groupCap ?? GROUP_CAP; + const toggledKeys = options.toggled ?? new Set(); + const expandedKeys = options.expanded ?? new Set(); + const parent = new Map(); + const visited = new Set([start]); + const rootBoundary = boundaryOf(start); + const levels: FlowGroup[][] = [[{ key: groupKey(0, rootBoundary), boundary: rootBoundary, refs: [start], count: 1, hidden: 0, collapsed: false, sources: [] }]]; + let frontier: IntelRef[] = [start]; + let total = 1; + for (let depth = 1; depth <= limit && frontier.length; depth++) { + const next: IntelRef[] = []; + for (const ref of frontier) for (const index of snapshot.indexes.outgoing[ref] ?? []) { + const edge = snapshot.edges[index]; + if (!edge.to || edge.kind === "navigation" || visited.has(edge.to)) continue; + visited.add(edge.to); + parent.set(edge.to, ref); + next.push(edge.to); + } + if (!next.length) break; + const byBoundary = new Map(); + const sourcesOf = new Map>(); + for (const ref of next) { + const boundary = boundaryOf(ref); + const bucket = byBoundary.get(boundary); + if (bucket) bucket.push(ref); else byBoundary.set(boundary, [ref]); + // Captured before the fold prunes `parent`, so a folded boundary still knows what feeds it. + const from = parent.get(ref); + if (from) (sourcesOf.get(boundary) ?? sourcesOf.set(boundary, new Set()).get(boundary) as Set).add(from); + } + const level: FlowGroup[] = []; + const carry: IntelRef[] = []; + for (const boundary of boundaries) { + const members = byBoundary.get(boundary); + if (!members) continue; + const index = level.length; + members.sort((a, b) => reachOf(snapshot, b) - reachOf(snapshot, a) || labelOf(a).localeCompare(labelOf(b))); + const key = groupKey(depth, boundary); + const open = toggledKeys.has(key) ? !openByDefault(depth, index) : openByDefault(depth, index); + const collapsed = !open; + const shown = collapsed ? [] : members.slice(0, expandedKeys.has(key) ? members.length : cap); + const kept = new Set(shown); + // A row that is not drawn has no card to trace from, so it seeds no edge and no next hop. + for (const ref of members) if (!kept.has(ref)) parent.delete(ref); + // A folded group reports its whole size on the header, so it needs no "+N more" as well. + level.push({ key, boundary, refs: shown, count: members.length, hidden: collapsed ? 0 : members.length - shown.length, collapsed, sources: [...(sourcesOf.get(boundary) ?? [])] }); + carry.push(...shown); + total += members.length; + } + levels.push(level); + frontier = carry; + } + return { start, levels, parent, total }; +} + +// ── Placing it ──────────────────────────────────────────────────────────────────────────────── + +export type FlowMetrics = { + card: { width: number; height: number }; gap: number; header: number; headerGap: number; + groupGap: number; column: number; pad: number; indent: number; more: number; +}; + +/** + * `column` is deliberately wide: the links between hops fan out across the whole gap, and at the + * old 52px they crossed each other into a thicket before reaching their cards. + */ +export const FLOW_METRICS: FlowMetrics = { + card: { width: 226, height: 40 }, gap: 8, header: 22, headerGap: 6, + groupGap: 20, column: 376, pad: 16, indent: 10, more: 22, +}; + +export type PlacedHeader = { key: string; boundary: Boundary; depth: number; count: number; shown: number; collapsed: boolean; sources: IntelRef[]; rect: Rect }; +export type PlacedCard = { ref: IntelRef; depth: number; rect: Rect }; +export type PlacedMore = { key: string; boundary: Boundary; depth: number; hidden: number; sources: IntelRef[]; rect: Rect }; +export type FlowPlacement = { + width: number; height: number; + headers: PlacedHeader[]; cards: PlacedCard[]; more: PlacedMore[]; + rectOf: Map; + /** Where each boundary header landed, so a folded group can still be drawn a connector. */ + headerRectOf: Map; +}; + +type Block = { headers: PlacedHeader[]; cards: PlacedCard[]; more: PlacedMore[]; width: number; height: number }; + +/** A level laid out at the origin: one stack of boundaries, to be shifted into its column. */ +function columnBlock(level: FlowGroup[], depth: number, metrics: FlowMetrics): Block { + const { card, gap, header, headerGap, groupGap, indent, more } = metrics; + const block: Block = { headers: [], cards: [], more: [], width: card.width, height: 0 }; + let y = 0; + for (const group of level) { + block.headers.push({ key: group.key, boundary: group.boundary, depth, count: group.count, shown: group.refs.length, collapsed: group.collapsed, sources: group.sources, rect: { x: 0, y, width: card.width, height: header } }); + y += header + headerGap; + for (const ref of group.refs) { + block.cards.push({ ref, depth, rect: { x: indent, y, width: card.width - indent, height: card.height } }); + y += card.height + gap; + } + if (group.hidden) { + block.more.push({ key: group.key, boundary: group.boundary, depth, hidden: group.hidden, sources: group.sources, rect: { x: indent, y, width: card.width - indent, height: more } }); + y += more + gap; + } + y += groupGap - (group.refs.length || group.hidden ? gap : 0); + } + block.height = Math.max(0, y - groupGap); + return block; +} + +function shift(block: Block, dx: number, dy: number) { + for (const item of [...block.headers, ...block.cards, ...block.more]) { item.rect.x += dx; item.rect.y += dy; } +} + +/** Places a built graph: one boundary stack per hop, read left to right. */ +export function placeFlow(graph: FlowGraph, metrics: FlowMetrics = FLOW_METRICS): FlowPlacement { + const blocks = graph.levels.map((level, depth) => depth === 0 + ? { headers: [], cards: [{ ref: level[0].refs[0], depth: 0, rect: { x: 0, y: 0, width: metrics.card.width, height: metrics.card.height } }], more: [], width: metrics.card.width, height: metrics.card.height } satisfies Block + : columnBlock(level, depth, metrics)); + // Every level is anchored to the start of the cross axis rather than centred on it. Centring + // reads better on a still graph, but folding one boundary then moves every other level, which + // throws away where the reader had panned to. A fixed origin keeps folds local. + blocks.forEach((block, depth) => shift(block, metrics.pad + depth * metrics.column, metrics.pad)); + const width = metrics.pad * 2 + (blocks.length - 1) * metrics.column + metrics.card.width; + const height = metrics.pad * 2 + Math.max(0, ...blocks.map(block => block.height)); + const placement: FlowPlacement = { width, height, headers: [], cards: [], more: [], rectOf: new Map(), headerRectOf: new Map() }; + for (const block of blocks) { + placement.headers.push(...block.headers); + placement.cards.push(...block.cards); + placement.more.push(...block.more); + for (const card of block.cards) placement.rectOf.set(card.ref, card.rect); + for (const header of block.headers) placement.headerRectOf.set(header.key, header.rect); + } + return placement; +} + +/** The curve from one placed box's right edge to the next one's left. */ +export function flowLink(from: Rect, to: Rect): string { + const x1 = from.x + from.width, y1 = from.y + from.height / 2, x2 = to.x, y2 = to.y + to.height / 2; + const bend = Math.max(8, (x2 - x1) * 0.45); + return `M${x1} ${y1} C${x1 + bend} ${y1} ${x2 - bend} ${y2} ${x2} ${y2}`; +} diff --git a/packages/ui/src/app-intelligence-panel.ts b/packages/ui/src/app-intelligence-panel.ts new file mode 100644 index 00000000..21f8f4e6 --- /dev/null +++ b/packages/ui/src/app-intelligence-panel.ts @@ -0,0 +1,161 @@ +import type { HostBridge } from "./host-bridge.ts"; +import type { AppIntelSnapshot, AppIntelStatus } from "@mobile-dev/shared/app-intelligence.ts"; +import { captureUiError, countUiEvent, measureUiOperation, recordUiTiming, setUiGauge } from "./telemetry.ts"; + +export type AppIntelligenceState = { + open: boolean; available: boolean; projectPath: string; snapshot: AppIntelSnapshot | null; + run: AppIntelStatus["run"]; busy: "loading" | "picking" | "generating" | "cancelling" | "clearing" | null; + error: string; selectedScreen: string | null; + /** Projects the panel remembers, most recent first, so switching needs no folder picker. */ + recents: string[]; + /** + * Boundary groups flipped away from their default fold (only the first hop opens by itself), + * and those opened past their row cap. Both are keyed ":". + */ + toggled: string[]; expanded: string[]; + /** + * The boundary whose connectors are drawn at full strength: ":" from a header, + * or "*:" from a filter chip, which lights that boundary at every hop. null dims all. + */ + litBoundary: string | null; + /** Boundaries the Flow may draw. null means every boundary, which is what a fresh graph shows. */ + boundaries: string[] | null; +}; + +function resultError(result: Awaited>) { + return result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "App Intelligence request failed."; +} + +export class AppIntelligencePanel { + private listeners = new Set<() => void>(); + private state: AppIntelligenceState = { open: false, available: false, projectPath: "", snapshot: null, run: null, busy: null, error: "", selectedScreen: null, recents: [], toggled: [], expanded: [], litBoundary: null, boundaries: null }; + private loadRevision = 0; + private app: HostBridge; + constructor(app: HostBridge) { this.app = app; } + subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); }; + getSnapshot = () => this.state; + private update(patch: Partial) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); } + // A panel that already has a cached graph reloads quietly: flipping to busy:"loading" on every + // reopen disabled the controls and reset nothing useful, since the cached graph is still valid. + setAvailable(available: boolean) { this.update({ available }); if (available && this.state.open) void this.load(undefined, { silent: Boolean(this.state.snapshot) }); } + show() { this.update({ open: true }); if (this.state.available) void this.load(undefined, { silent: Boolean(this.state.snapshot) }); } + hide() { this.update({ open: false }); } + dispose() { this.loadRevision++; this.listeners.clear(); } + // A new root means new hops, so the folds from the old one would land on unrelated boundaries. + selectScreen(selectedScreen: string | null) { this.update({ selectedScreen, toggled: [], expanded: [], litBoundary: null }); } + /** `open` is what the click is asking for; the state stores only the flip from the default. */ + toggleGroup(key: string, open: boolean) { + countUiEvent(open ? "ui.app_intel.group_expand" : "ui.app_intel.group_collapse"); + const flipped = this.state.toggled.includes(key); + this.update({ toggled: flipped ? this.state.toggled.filter(entry => entry !== key) : [...this.state.toggled, key] }); + } + /** Clicking the lit boundary again puts every connector back to the same dim weight. */ + litBoundaryTo(key: string | null) { + const next = key === this.state.litBoundary ? null : key; + if (next) countUiEvent("ui.app_intel.boundary_highlight"); + this.update({ litBoundary: next }); + } + + /** Opens one boundary past its row cap. Folding it away later also forgets that it was opened. */ + showAllIn(key: string) { + if (this.state.expanded.includes(key)) return; + countUiEvent("ui.app_intel.group_show_all"); + this.update({ expanded: [...this.state.expanded, key] }); + } + setBoundaries(boundaries: string[] | null) { countUiEvent("ui.app_intel.boundary_filter"); this.update({ boundaries }); } + toggleBoundary(boundary: string, available: readonly string[]) { + const current = this.state.boundaries ?? available; + const next = current.includes(boundary) ? current.filter(entry => entry !== boundary) : [...current, boundary]; + this.setBoundaries(next.length === available.length && available.every(entry => next.includes(entry)) ? null : next); + } + + async load(projectPath?: string, { silent = false }: { silent?: boolean } = {}) { + if (!this.state.available || this.state.busy === "generating") return; + const revision = ++this.loadRevision; + this.update(silent ? { error: "" } : { busy: "loading", error: "" }); + try { + const result = await measureUiOperation("ui.app_intel.load", "ui.app_intel.load_failed", () => this.app.callServerTool({ name: "mobile_app_intel_snapshot", arguments: projectPath ? { projectPath } : {} })); + if (revision !== this.loadRevision) return; + // Even a failed read carries the remembered projects, so the picker still has them to offer. + if (result.isError) { this.update({ recents: (result.structuredContent as { recents?: string[] } | undefined)?.recents ?? this.state.recents }); throw new Error(resultError(result)); } + const value = result.structuredContent as { projectPath: string; snapshot: AppIntelSnapshot | null; recents?: string[] }; + setUiGauge("ui.app_intel.files", Object.keys(value.snapshot?.files ?? {}).length); + setUiGauge("ui.app_intel.screens", Object.keys(value.snapshot?.screens ?? {}).length); + const screen = value.snapshot && this.state.selectedScreen && value.snapshot.screens[this.state.selectedScreen] ? this.state.selectedScreen : null; + this.update({ projectPath: value.projectPath, snapshot: value.snapshot, recents: value.recents ?? this.state.recents, busy: null, selectedScreen: screen }); + } catch (error) { + if (revision !== this.loadRevision) return; + const message = error instanceof Error ? error.message : String(error); + this.update({ busy: null, error: /No project selected/.test(message) ? "" : message, projectPath: /No project selected/.test(message) ? "" : this.state.projectPath }); + } + } + + async pickProject() { + if (!this.state.available) return; + this.loadRevision++; + this.update({ busy: "picking", error: "" }); + try { + const result = await measureUiOperation("ui.app_intel.pick", "ui.app_intel.pick_failed", () => this.app.callServerTool({ name: "mobile_pick_app_intel_project", arguments: {} })); + if (result.isError) throw new Error(resultError(result)); + const value = result.structuredContent as { picked: boolean; projectPath?: string }; + if (!value.picked) { countUiEvent("ui.app_intel.pick_cancelled"); this.update({ busy: null }); return; } + this.update({ projectPath: value.projectPath ?? "", busy: null, snapshot: null, selectedScreen: null, toggled: [], expanded: [], litBoundary: null, boundaries: null }); + await this.load(value.projectPath); + } catch (error) { this.update({ busy: null, error: error instanceof Error ? error.message : String(error) }); captureUiError(error, "app_intel.pick"); } + } + + /** Switches to a remembered project without reopening the host folder picker. */ + async selectProject(projectPath: string) { + if (!this.state.available || projectPath === this.state.projectPath) return; + this.loadRevision++; + this.update({ busy: "picking", error: "", snapshot: null, selectedScreen: null, toggled: [], expanded: [], litBoundary: null, boundaries: null }); + try { + const result = await this.app.callServerTool({ name: "mobile_select_app_intel_project", arguments: { projectPath } }); + if (result.isError) throw new Error(resultError(result)); + const value = result.structuredContent as { projectPath: string; snapshot: AppIntelSnapshot | null; recents?: string[] }; + countUiEvent("ui.app_intel.project_switch"); + this.update({ projectPath: value.projectPath, snapshot: value.snapshot, recents: value.recents ?? this.state.recents, busy: null }); + } catch (error) { this.update({ busy: null, error: error instanceof Error ? error.message : String(error) }); captureUiError(error, "app_intel.select"); } + } + + async generate() { + if (!this.state.available || !this.state.projectPath) return; + this.loadRevision++; + this.update({ busy: "generating", error: "", run: { runId: "ui", phase: "analyzing", message: "Discovering screens and dependencies" } }); + const startedAt = performance.now(); + try { + const result = await this.app.callServerTool({ name: "mobile_generate_app_intel", arguments: { projectPath: this.state.projectPath } }); + if (result.isError) throw new Error(resultError(result)); + countUiEvent("ui.app_intel.generate_success"); + this.update({ busy: null, run: null }); + await this.load(this.state.projectPath); + } catch (error) { + countUiEvent("ui.app_intel.generate_failed"); + captureUiError(error, "app_intel.generate"); + this.update({ error: error instanceof Error ? error.message : String(error), busy: null, run: null }); + } finally { + recordUiTiming("ui.app_intel.generate", performance.now() - startedAt); + } + } + + /** Throws the saved graph away and leaves the project selected, ready to generate again. */ + async clear() { + if (!this.state.available || !this.state.projectPath || this.state.busy !== null) return; + this.loadRevision++; + this.update({ busy: "clearing", error: "" }); + try { + const result = await this.app.callServerTool({ name: "mobile_clear_app_intel", arguments: { projectPath: this.state.projectPath } }); + if (result.isError) throw new Error(resultError(result)); + countUiEvent("ui.app_intel.clear"); + this.update({ busy: null, snapshot: null, selectedScreen: null, toggled: [], expanded: [], litBoundary: null, boundaries: null }); + } catch (error) { this.update({ busy: null, error: error instanceof Error ? error.message : String(error) }); captureUiError(error, "app_intel.clear"); } + } + + async cancel() { + if (this.state.busy !== "generating") return; + this.update({ busy: "cancelling" }); + try { await this.app.callServerTool({ name: "mobile_cancel_app_intel", arguments: { projectPath: this.state.projectPath } }); } + finally { this.update({ busy: null, run: null }); } + } +} + diff --git a/packages/ui/src/components/app-intelligence-view.tsx b/packages/ui/src/components/app-intelligence-view.tsx new file mode 100644 index 00000000..5d59bdac --- /dev/null +++ b/packages/ui/src/components/app-intelligence-view.tsx @@ -0,0 +1,492 @@ +import { ChevronRightIcon, EraserIcon, FolderOpenIcon, ListFilterIcon, MaximizeIcon, MinusIcon, NetworkIcon, PanelRightCloseIcon, PanelRightOpenIcon, PlusIcon, RefreshCwIcon, SearchIcon, SquareIcon } from "lucide-react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { AppIntelligencePanel } from "../app-intelligence-panel.ts"; +import type { AppIntelSnapshot, IntelRef } from "@mobile-dev/shared/app-intelligence.ts"; +import { fileRef, screenRef } from "@mobile-dev/shared/app-intelligence.ts"; +import type { Boundary, NodePlatforms } from "../app-intelligence-layout.ts"; +import { boundariesIn, boundaryColor, boundaryLabel, boundaryOf, buildFlow, clampName, depthHue, depthTint, ellipsize, flowLink, labelOf, pathOf, placeFlow, platformsFor, reachOf, FLOW_METRICS } from "../app-intelligence-layout.ts"; +import { recordUiTiming, setUiGauge } from "../telemetry.ts"; +import { Alert, AlertDescription } from "./ui/alert.tsx"; +import { Button } from "./ui/button.tsx"; +import { ConfirmButton } from "./confirm-button.tsx"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group.tsx"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/base-select.tsx"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover.tsx"; + +const BROWSE = "\u0000browse"; +const folderName = (path: string) => path.split("/").at(-1) || path; + +/** + * The project picker, on the same Select as the workspace's tool and platform menus. It remembers + * what has been opened, so switching between apps is a menu rather than a trip through the host + * folder picker; the last entry still opens that picker. + */ +function ProjectPicker({ projectPath, recents, disabled, onSelect, onBrowse }: { + projectPath: string; recents: readonly string[]; disabled: boolean; + onSelect: (path: string) => void; onBrowse: () => void; +}) { + const options = [...new Set([...(projectPath ? [projectPath] : []), ...recents])]; + if (!options.length) return ; + return ; +} + +const platformEdge: Record = { ios: "bg-sky-500", android: "bg-lime-500", both: "bg-transparent" }; +const platformFill: Record = { ios: "fill-sky-500", android: "fill-lime-500", both: "fill-transparent" }; +const platformLabel = (platforms: NodePlatforms) => platforms === "both" ? "iOS + Android" : platforms === "ios" ? "iOS only" : "Android only"; + +/** Measures a box through a callback ref, so it also measures one that mounts later. */ +function useMeasured() { + const [element, setElement] = useState(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + useLayoutEffect(() => { + if (!element) return; + const read = () => setSize(previous => { + const next = { width: element.clientWidth, height: element.clientHeight }; + return next.width === previous.width && next.height === previous.height ? previous : next; + }); + read(); + if (typeof ResizeObserver !== "function") return; + const observer = new ResizeObserver(read); + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + return [setElement, size, element] as const; +} + +const ZOOM = { min: 0.3, max: 2.5, step: 1.25 } as const; +const clampZoom = (value: number) => Math.min(ZOOM.max, Math.max(ZOOM.min, value)); + +/** + * Pan and zoom for the Flow. The board usually fits its pane, so moving scrollLeft did nothing; + * this transforms the canvas instead, which works whatever its size. A drag that actually moves + * swallows the click that follows it, so panning never re-roots the graph. + * + * The view state lives in a ref and is written straight to the style, so folding a boundary — a + * React re-render — never moves the board. Only an explicit reset or a new root recentres it. + */ +function usePanZoom(element: HTMLElement | null, resetKey: string) { + const view = useRef({ x: 0, y: 0, scale: 1 }); + const [zoom, setZoom] = useState(1); + const apply = useRef<(next: Partial<{ x: number; y: number; scale: number }>) => void>(() => {}); + useEffect(() => { + if (!element) return; + const canvas = element.firstElementChild as HTMLElement | null; + if (!canvas) return; + canvas.style.transformOrigin = "0 0"; + const draw = () => { + const { x, y, scale } = view.current; + canvas.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px) scale(${scale})`; + }; + apply.current = next => { + if (next.scale !== undefined) next.scale = clampZoom(next.scale); + view.current = { ...view.current, ...next }; + setZoom(view.current.scale); + draw(); + }; + draw(); + let origin: { x: number; y: number; panX: number; panY: number } | null = null; + let moved = false; + const swallow = (event: Event) => { event.preventDefault(); event.stopPropagation(); }; + const down = (event: PointerEvent) => { + if (event.button !== 0) return; + origin = { x: event.clientX, y: event.clientY, panX: view.current.x, panY: view.current.y }; + moved = false; + }; + const move = (event: PointerEvent) => { + if (!origin) return; + const dx = event.clientX - origin.x, dy = event.clientY - origin.y; + if (!moved && Math.abs(dx) < 4 && Math.abs(dy) < 4) return; + moved = true; + element.dataset.panning = "true"; + view.current.x = origin.panX + dx; + view.current.y = origin.panY + dy; + draw(); + }; + const up = () => { + if (moved) element.addEventListener("click", swallow, { capture: true, once: true }); + origin = null; + moved = false; + delete element.dataset.panning; + }; + const wheel = (event: WheelEvent) => { + event.preventDefault(); + // Zoom about the pointer, so the card under the cursor stays under it. + if (event.ctrlKey || event.metaKey) { + const box = element.getBoundingClientRect(); + const pointerX = event.clientX - box.left, pointerY = event.clientY - box.top; + const { x, y, scale } = view.current; + const next = clampZoom(scale * Math.exp(-event.deltaY / 240)); + const ratio = next / scale; + apply.current({ scale: next, x: pointerX - (pointerX - x) * ratio, y: pointerY - (pointerY - y) * ratio }); + return; + } + view.current.x -= event.deltaX; + view.current.y -= event.deltaY; + draw(); + }; + const recenter = () => apply.current({ x: 0, y: 0, scale: 1 }); + element.addEventListener("pointerdown", down); + element.addEventListener("wheel", wheel, { passive: false }); + element.addEventListener("dblclick", recenter); + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + window.addEventListener("pointercancel", up); + return () => { + element.removeEventListener("pointerdown", down); + element.removeEventListener("wheel", wheel); + element.removeEventListener("dblclick", recenter); + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", up); + }; + }, [element]); + // A new root or reading direction is a new graph, so it starts from the default view. + useEffect(() => { apply.current({ x: 0, y: 0, scale: 1 }); }, [resetKey]); + const by = useCallback((factor: number) => { + const { scale, x, y } = view.current; + const next = clampZoom(scale * factor); + const box = element?.getBoundingClientRect(); + const centreX = (box?.width ?? 0) / 2, centreY = (box?.height ?? 0) / 2; + const ratio = next / scale; + apply.current({ scale: next, x: centreX - (centreX - x) * ratio, y: centreY - (centreY - y) * ratio }); + }, [element]); + const reset = useCallback(() => apply.current({ x: 0, y: 0, scale: 1 }), []); + return { zoom, zoomIn: useCallback(() => by(ZOOM.step), [by]), zoomOut: useCallback(() => by(1 / ZOOM.step), [by]), reset }; +} + +type Hover = { title: string; detail: string; x: number; y: number } | null; + +const NAME_CHARS = 24; +/** Characters the subtitle can take, once the boundary tag beside it has had its share. */ +const pathChars = (boundary: string) => Math.max(8, Math.floor((FLOW_METRICS.card.width - 30 - boundary.length * 5.4) / 5.05)); + +/** The three weights a connector is drawn at: dim by default, warmer on hover, full when lit. */ +const LINK = { dim: 0.2, hovered: 0.6, lit: 1 } as const; + +function FlowView({ snapshot, start, toggled, expanded, litBoundary, height, onToggleGroup, onLight, onShowAll, onHover }: { + snapshot: AppIntelSnapshot; start: IntelRef; + toggled: ReadonlySet; expanded: ReadonlySet; litBoundary: string | null; height: number; + onToggleGroup: (key: string, open: boolean) => void; onLight: (key: string) => void; + onShowAll: (key: string) => void; onHover: (hover: Hover) => void; +}) { + const [lit, setLit] = useState(null); + const { graph, placement } = useMemo(() => { + const startedAt = performance.now(); + const built = buildFlow(snapshot, start, { toggled, expanded }); + const placed = placeFlow(built); + recordUiTiming("ui.app_intel.flow_layout", performance.now() - startedAt); + return { graph: built, placement: placed }; + }, [snapshot, start, toggled, expanded]); + useEffect(() => { setUiGauge("ui.app_intel.flow_nodes", placement.cards.length); }, [placement]); + + const path = new Set(); + for (let cursor = lit; cursor; cursor = graph.parent.get(cursor) ?? null) path.add(cursor); + // A chip lights its boundary at every hop; a header lights only its own. + const isLit = (boundary: Boundary, depth: number) => litBoundary === `${depth}:${boundary}` || litBoundary === `*:${boundary}`; + const weight = (boundary: Boundary, depth: number, hovered: boolean) => + isLit(boundary, depth) ? LINK.lit : hovered ? LINK.hovered : LINK.dim; + + return + {placement.cards.map(({ ref, depth, rect }) => { + const from = graph.parent.get(ref); + const origin = from && placement.rectOf.get(from); + if (!origin) return null; + const opacity = weight(boundaryOf(ref), depth, path.has(ref)); + return ; + })} + {/* A folded boundary keeps its connectors: they land on the header instead of on its rows, + so the shape of what is hidden stays on screen rather than vanishing with the cards. */} + {[...placement.headers.filter(header => header.collapsed).map(header => ({ ...header, target: header.rect, kind: "group" })), + ...placement.more.map(entry => ({ ...entry, target: entry.rect, kind: "more" }))] + .flatMap(entry => entry.sources.map(source => { + const origin = placement.rectOf.get(source); + if (!origin) return null; + const opacity = weight(entry.boundary, entry.depth, false); + return ; + }))} + {placement.headers.map(header => { + const colour = boundaryColor(header.boundary); + const summary = header.collapsed ? `${header.count} hidden` : `${header.shown} of ${header.count}`; + const on = isLit(header.boundary, header.depth); + const pin = { x: header.rect.x + header.rect.width - 13, y: header.rect.y + header.rect.height / 2 }; + return + onToggleGroup(header.key, header.collapsed)} + onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onToggleGroup(header.key, header.collapsed); } }}> + + + {boundaryLabel[header.boundary]} + {header.collapsed ? header.count : `${header.shown}/${header.count}`} + + {/* The pin only lights this boundary's connectors, so folding stays the header's own job. */} + onLight(header.key)} + onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onLight(header.key); } }} /> + ; + })} + {placement.more.map(entry => onShowAll(entry.key)} + onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onShowAll(entry.key); } }}> + + +{entry.hidden} more — show all + )} + {placement.cards.map(({ ref, depth, rect }) => { + const reach = reachOf(snapshot, ref); + const screen = ref.startsWith("screen:") ? snapshot.screens[ref.slice(7)] : null; + const platforms = screen ? "both" as NodePlatforms : platformsFor(snapshot, ref); + const boundary = boundaryOf(ref); + const detail = `${boundaryLabel[boundary]}${screen ? ` · ${screen.route ?? ref.slice(7)}` : ` · reach ${reach}`}${platforms === "both" ? "" : ` · ${platformLabel(platforms)}`}`; + const on = path.has(ref); + return { setLit(ref); onHover({ title: ref.replace(/^(?:file|package|screen):/, ""), detail, x: event.clientX, y: event.clientY }); }} + onPointerLeave={() => { setLit(null); onHover(null); }} + > + + + {platforms !== "both" && } + {clampName(labelOf(ref), NAME_CHARS)} + {boundary} + {ellipsize(screen ? screen.route ?? "" : pathOf(ref), pathChars(boundary))} + {!screen && {reach}} + ; + })} + ; +} + +function BoundaryOptions({ available, selected, lit, onToggle, onLight, onAll }: { + available: Array<{ boundary: Boundary; count: number }>; selected: ReadonlySet; lit: string | null; + onToggle: (boundary: Boundary) => void; onLight?: (boundary: Boundary) => void; onAll: (checked: boolean) => void; +}) { + const all = available.length > 0 && available.every(entry => selected.has(entry.boundary)); + return <> + +
+ {available.map(({ boundary, count }) => { + const on = selected.has(boundary); + const highlighted = lit === `*:${boundary}`; + return
+ + {onLight && } +
; + })} +
+ ; +} + +/** + * The entry-point filter: a search field plus one checkbox per boundary, in a dropdown so the + * list beside it keeps the width for names and paths. + */ +function BoundaryFilter({ available, selected, lit, query, onQueryChange, onToggle, onLight, onAll }: { + available: Array<{ boundary: Boundary; count: number }>; selected: ReadonlySet; lit: string | null; + query: string; onQueryChange: (value: string) => void; + onToggle: (boundary: Boundary) => void; onLight: (boundary: Boundary) => void; onAll: (checked: boolean) => void; +}) { + const all = available.length > 0 && available.every(entry => selected.has(entry.boundary)); + const activeCount = available.filter(entry => selected.has(entry.boundary)).length; + return + + + + + onQueryChange(event.target.value)} /> + {available.length > 0 &&
} +
+
; +} + +/** The generated project's home: its entry-point boundaries arranged around the project. */ +function ProjectOverview({ projectName, available, activeBoundary, onSelect }: { + projectName: string; available: Array<{ boundary: Boundary; count: number }>; + activeBoundary: string | null; onSelect: (boundary: Boundary) => void; +}) { + const total = available.reduce((sum, entry) => sum + entry.count, 0); + const centre = { x: 300, y: 210 }; + const points = available.map((entry, index) => { + const angle = -Math.PI / 2 + index * (Math.PI * 2 / Math.max(available.length, 1)); + return { ...entry, x: centre.x + Math.cos(angle) * 220, y: centre.y + Math.sin(angle) * 145 }; + }); + + return
+ +
+
+ + {projectName} + {total} {total === 1 ? "entry point" : "entry points"} +
+
+ {points.map(({ boundary, count, x, y }, index) => { + const active = activeBoundary === boundary; + return
+ +
; + })} + {available.length === 0 &&

No entry points were found in this graph.

} + {available.length > 0 &&

Select an area to browse its entry points

} +
; +} + +export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: { panel: AppIntelligencePanel }) { + const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot); + const [query, setQuery] = useState(""); + const [railOpen, setRailOpen] = useState(false); + const [asideOpen, setAsideOpen] = useState(true); + const [hover, setHover] = useState(null); + const [flowRef, flowSize, flowBox] = useMeasured(); + const snapshot = state.snapshot; + const search = query.trim().toLowerCase(); + + // The rail is what roots the graph. A card is a place in that screen's tree, not a tree of its + // own: re-rooting on a click meant any stray click threw away where the reader had got to. + const selected = state.selectedScreen && snapshot?.screens[state.selectedScreen] ? state.selectedScreen : null; + const flowStart: IntelRef | null = selected ? screenRef(selected) : null; + const toggled = useMemo(() => new Set(state.toggled), [state.toggled]); + const expanded = useMemo(() => new Set(state.expanded), [state.expanded]); + + // An entry point is classified by the file it renders: the ids are routes, which say nothing. + const entries = useMemo(() => Object.entries(snapshot?.screens ?? {}) + .map(([id, screen]) => ({ id, screen, boundary: screen.source ? boundaryOf(fileRef(screen.source)) : "entry" as Boundary })), [snapshot]); + const available = useMemo(() => boundariesIn(entries.map(entry => entry.boundary)), [entries]); + const selectedBoundaries = useMemo(() => new Set(state.boundaries ?? available.map(entry => entry.boundary)), [state.boundaries, available]); + const toggleBoundary = useCallback((boundary: Boundary) => panel.toggleBoundary(boundary, available.map(entry => entry.boundary)), [panel, available]); + // Re-centre the canvas whenever the graph it holds changes, so a new trace is never panned away. + const { zoom, zoomIn, zoomOut, reset } = usePanZoom(flowBox, flowStart ?? ""); + const screens = useMemo(() => entries.filter(({ id, screen, boundary }) => selectedBoundaries.has(boundary) + && (!search || `${id} ${screen.title} ${screen.route ?? ""} ${screen.source ?? ""}`.toLowerCase().includes(search))), [entries, selectedBoundaries, search]); + + return
+
+ void panel.selectProject(path)} onBrowse={() => void panel.pickProject()} /> +
+ {state.busy === "generating" || state.busy === "cancelling" + ? + : <> + + {snapshot && void panel.clear()} + className="text-destructive hover:bg-destructive/10 hover:text-destructive">Clear} + } +
+ {state.error && {state.error}} + {state.run &&
{state.run.message}
} + {/* A reopened panel starts blank and reloads what it remembered: showing the empty states + while that load is in flight would flash the wrong screen before the real one lands. */} + {state.busy === "loading" && !snapshot ?

Loading App Intelligence…

+ : !state.projectPath ?

Map a React Native app

Choose a project to discover Expo Router and React Navigation entries, then trace their native dependencies.

+ : !snapshot ?

No App Intelligence yet

Generate a static iOS + Android entry-point and dependency graph.

+ :
+
+
+ {snapshot.screens[selected ?? ""]?.title ?? "Project overview"} +
+
+ + {Math.round(zoom * 100)}% + + +
+ +
+
+
+ {flowStart ?
panel.toggleGroup(key, open)} onLight={key => panel.litBoundaryTo(key)} onShowAll={key => panel.showAllIn(key)} onHover={setHover} />
+ :
+ { setQuery(""); panel.setBoundaries([boundary]); setAsideOpen(true); setRailOpen(true); }} /> +
} +
+

{flowStart + ? "Each hop groups its imports by boundary and opens the first of them — click another to drill in, or its dot to light up its connectors. Drag or scroll to pan, ⌘/Ctrl-scroll or the buttons to zoom, double-click to reset." + : "Areas group the app's entry points by their source boundary. Choose one to filter and open the matching list."}

+
+ {asideOpen && } +
} + {hover &&
+
{hover.title}
+
{hover.detail}
+
} +
; +}); diff --git a/packages/ui/src/components/confirm-button.tsx b/packages/ui/src/components/confirm-button.tsx new file mode 100644 index 00000000..b6bc14a3 --- /dev/null +++ b/packages/ui/src/components/confirm-button.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import { Button } from "./ui/button"; +import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "./ui/popover"; + +/** + * A button that asks before it acts, in a popover anchored to itself. Shared by every panel with + * a destructive control, so "clear this" reads and behaves the same wherever it appears. + */ +export function ConfirmButton({ children, title, description, action, disabled, onConfirm, className, variant = "ghost", size = "xs", id }: { + children: ReactNode; title: string; description: string; action: string; disabled?: boolean; + onConfirm: () => void; className?: string; variant?: "ghost" | "outline"; size?: "xs" | "sm"; id?: string; +}) { + return + + +

{title}?

+

{description}

+
+ + +
+
+
; +} diff --git a/packages/ui/src/components/storage-browse.tsx b/packages/ui/src/components/storage-browse.tsx index 837b9384..e0c17ba8 100644 --- a/packages/ui/src/components/storage-browse.tsx +++ b/packages/ui/src/components/storage-browse.tsx @@ -7,7 +7,8 @@ import { isWorking } from "../storage-panel.ts"; import type { StorageFileView, StoragePanel, StorageTableView } from "../storage-panel.ts"; import { formatBytes, isSdkStore, storeJson, storeTitle } from "../storage-labels.ts"; import { EntryRow, JsonTree, entryMatches } from "./storage-entries"; -import { ConfirmButton, FilesIcon, KindIcon, SidebarRow } from "./storage-parts"; +import { ConfirmButton } from "./confirm-button"; +import { FilesIcon, KindIcon, SidebarRow } from "./storage-parts"; import { Alert, AlertDescription } from "./ui/alert"; import { Button } from "./ui/button"; import { ButtonGroup } from "./ui/button-group"; diff --git a/packages/ui/src/components/storage-parts.tsx b/packages/ui/src/components/storage-parts.tsx index a42e4684..3deb0924 100644 --- a/packages/ui/src/components/storage-parts.tsx +++ b/packages/ui/src/components/storage-parts.tsx @@ -2,9 +2,7 @@ import { BracesIcon, DatabaseIcon, FolderIcon, LayersIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; import type { StoreKind } from "@mobile-dev/shared/app-storage.ts"; -import { Button } from "./ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "./ui/empty"; -import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "./ui/popover"; // Class names stay literal so Tailwind finds them. A ring of the same hue keeps a chip's edge visible on grey rows. const chipColors = { @@ -52,20 +50,6 @@ export function SidebarRow({ icon, title, subtitle, trailing, selected, tooltip, ; } -export function ConfirmButton({ children, title, description, action, disabled, onConfirm, className, variant = "ghost", size = "xs" }: { children: ReactNode; title: string; description: string; action: string; disabled?: boolean; onConfirm: () => void; className?: string; variant?: "ghost" | "outline"; size?: "xs" | "sm" }) { - return - - -

{title}?

-

{description}

-
- - -
-
-
; -} - export function StorageEmpty({ title, description }: { title: string; description: string }) { return diff --git a/packages/ui/src/components/storage-view.tsx b/packages/ui/src/components/storage-view.tsx index 36d4af3b..4eb7c0e6 100644 --- a/packages/ui/src/components/storage-view.tsx +++ b/packages/ui/src/components/storage-view.tsx @@ -8,7 +8,8 @@ import { clockTime } from "../storage-labels.ts"; import { AppAvatar } from "./app-avatar"; import { BrowseView } from "./storage-browse"; import { ChangesView } from "./storage-changes"; -import { ConfirmButton, StorageEmpty } from "./storage-parts"; +import { ConfirmButton } from "./confirm-button"; +import { StorageEmpty } from "./storage-parts"; import { Alert, AlertDescription } from "./ui/alert"; import { Button } from "./ui/button"; import { ButtonGroup } from "./ui/button-group"; diff --git a/packages/ui/src/style.css b/packages/ui/src/style.css index 451dd41a..0d761006 100644 --- a/packages/ui/src/style.css +++ b/packages/ui/src/style.css @@ -30,6 +30,23 @@ html:is([data-view="recording"], [data-view="comparison"], [data-view="tracesift .recording-track[data-reveal="pending"] .recording-change-highlight { opacity: 0; } .recording-track[data-reveal="finished"] .recording-change-highlight { animation: recording-highlight-reveal 150ms ease-out 250ms both; } @keyframes recording-highlight-reveal { from { opacity: 0; } to { opacity: 1; } } +.app-intel-area-node { animation: app-intel-area-prompt 7s ease-in-out infinite; will-change: transform; } +.app-intel-area-node:hover, .app-intel-area-node:focus-visible { animation: none; transform: translateY(-2px) scale(1.03); } +@keyframes app-intel-area-prompt { + 0%, 8%, 100% { transform: scale(1); } + 4% { transform: scale(1.07); } +} +.app-intel-project-orbit { opacity: 0; transform-box: fill-box; transform-origin: center; animation: app-intel-orbit-expand 4.8s cubic-bezier(.22,.75,.25,1) infinite; } +@keyframes app-intel-orbit-expand { + 0% { opacity: 0; transform: scale(.15); } + 16% { opacity: .13; } + 65% { opacity: .055; transform: scale(1); } + 78%, 100% { opacity: 0; transform: scale(1); } +} +@media (prefers-reduced-motion: reduce) { + .app-intel-area-node { animation: none; } + .app-intel-project-orbit { animation: none; opacity: .06; transform: none; } +} .recording-sidebar { display: flex; flex-direction: column; min-width: 0; padding: 16px; border-top: 1px solid var(--border); gap: 16px; } .recording-overview { border: 1px solid var(--border); border-radius: 10px; padding: 12px; } .recording-overview-bar { position: relative; height: 18px; margin-top: 14px; border-radius: 5px; background: var(--muted); overflow: hidden; } diff --git a/packages/ui/src/workspace-tools.ts b/packages/ui/src/workspace-tools.ts index 15c90454..6407cdb0 100644 --- a/packages/ui/src/workspace-tools.ts +++ b/packages/ui/src/workspace-tools.ts @@ -7,12 +7,13 @@ import { PerformancePanel } from "./performance-panel.ts"; import { StoragePanel } from "./storage-panel.ts"; import { markUiSurfaceReady, setUiSurface } from "./telemetry.ts"; import { TraceSiftPanel } from "./tracesift-panel.ts"; +import { AppIntelligencePanel } from "./app-intelligence-panel.ts"; -export const toolNames = ["logs", "performance", "storage", "tracesift", "app-flow"] as const; +export const toolNames = ["logs", "performance", "storage", "app-intel", "tracesift", "app-flow"] as const; export type ToolName = typeof toolNames[number]; export const isToolName = (value: unknown): value is ToolName => toolNames.includes(value as ToolName); -type Panels = { context: PanelContext; deviceApps: DeviceAppsStore; logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; tracesift: TraceSiftPanel; "app-flow": AppFlowPanel }; +type Panels = { context: PanelContext; deviceApps: DeviceAppsStore; logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; "app-intel": AppIntelligencePanel; tracesift: TraceSiftPanel; "app-flow": AppFlowPanel }; /** The tool panels every host shows beside its devices; hosts only choose the selected tool and draw chrome. */ export class WorkspaceTools { @@ -21,6 +22,7 @@ export class WorkspaceTools { readonly logs: LogsPanel; readonly performance: PerformancePanel; readonly storage: StoragePanel; + readonly "app-intel": AppIntelligencePanel; readonly tracesift: TraceSiftPanel; readonly "app-flow": AppFlowPanel; @@ -33,13 +35,14 @@ export class WorkspaceTools { logs: new LogsPanel(host, context, deviceApps), performance: new PerformancePanel(host, deviceApps), storage: new StoragePanel(host, deviceApps), + "app-intel": new AppIntelligencePanel(host), tracesift: new TraceSiftPanel(host, attachments), "app-flow": new AppFlowPanel(host), }); } constructor(panels: Panels) { - ({ context: this.context, deviceApps: this.deviceApps, logs: this.logs, performance: this.performance, storage: this.storage, tracesift: this.tracesift, "app-flow": this["app-flow"] } = panels); + ({ context: this.context, deviceApps: this.deviceApps, logs: this.logs, performance: this.performance, storage: this.storage, "app-intel": this["app-intel"], tracesift: this.tracesift, "app-flow": this["app-flow"] } = panels); } panel(name: ToolName) { return this[name]; } @@ -82,6 +85,7 @@ export class WorkspaceTools { this.deviceApps.dispose(); this.storage.dispose(); this.tracesift.dispose(); + this["app-intel"].dispose(); await Promise.allSettled([this.logs.dispose(), this.performance.dispose(), this["app-flow"].dispose()]); } } diff --git a/packages/ui/test/app-intelligence-layout.test.ts b/packages/ui/test/app-intelligence-layout.test.ts new file mode 100644 index 00000000..2e0047cc --- /dev/null +++ b/packages/ui/test/app-intelligence-layout.test.ts @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { AppIntelSnapshot, IntelEdge } from "@mobile-dev/shared/app-intelligence.ts"; +import { FLOW_METRICS, boundariesIn, boundaryOf, buildFlow, clampName, depthHue, depthTint, ellipsize, flowLink, groupKey, labelOf, openByDefault, pathOf, placeFlow, platformsFor } from "../src/app-intelligence-layout.ts"; + +function snapshotOf(edges: IntelEdge[], files: Record, packages: Record = {}): AppIntelSnapshot { + const indexes: AppIntelSnapshot["indexes"] = { screens: Object.create(null), incoming: Object.create(null), outgoing: Object.create(null) }; + edges.forEach((edge, index) => { (indexes.outgoing[edge.from] ??= []).push(index); if (edge.to) (indexes.incoming[edge.to] ??= []).push(index); }); + return { + formatVersion: 1, + meta: { projectId: "p", generation: "g", analyzedAt: "", analyzerVersion: "t", sourceFingerprint: "f" }, + files: Object.fromEntries(Object.entries(files).map(([path, reach]) => [path, { fingerprint: "x", kind: "source" as const, reach }])), + packages: Object.fromEntries(Object.entries(packages).map(([name, reach]) => [name, { name, reach, entry: null }])), + screens: { home: { title: "Home", route: "/", source: "src/screens/home.tsx", layouts: [] } }, + edges, diagnostics: [], indexes, + }; +} + +const importEdge = (from: string, to: string, platforms: IntelEdge["platforms"] = ["ios", "android"]): IntelEdge => ({ from: from as IntelEdge["from"], to: to as IntelEdge["to"], kind: "import", platforms }); + +test("a file reached only on one platform reports that platform", () => { + const snapshot = snapshotOf([ + importEdge("file:src/screen.tsx", "file:src/value.ios.ts", ["ios"]), + importEdge("file:src/screen.tsx", "file:src/both.ts"), + ], { "src/screen.tsx": 1, "src/value.ios.ts": 1, "src/both.ts": 1 }); + assert.equal(platformsFor(snapshot, "file:src/value.ios.ts"), "ios"); + assert.equal(platformsFor(snapshot, "file:src/both.ts"), "both"); + assert.equal(platformsFor(snapshot, "file:src/screen.tsx"), "both", "nothing reaches it, so it is not platform-specific"); +}); + +const refsIn = (level: ReturnType["levels"][number]) => level.flatMap(group => group.refs); +const groupsIn = (level: ReturnType["levels"][number]) => level.map(group => group.boundary); + +test("buildFlow terminates on an import cycle", () => { + const snapshot = snapshotOf([ + importEdge("file:a.ts", "file:b.ts"), + importEdge("file:b.ts", "file:a.ts"), + ], { "a.ts": 1, "b.ts": 1 }); + const flow = buildFlow(snapshot, "file:a.ts"); + assert.deepEqual(refsIn(flow.levels[1]), ["file:b.ts"]); + assert.equal(flow.levels[2], undefined, "a.ts is already visited, so the walk stops"); +}); + +/** One screen reaching a component, a hook and two packages: enough to group, fold and overflow. */ +function groupedSnapshot() { + return snapshotOf([ + { from: "screen:home", to: "file:src/screens/home.tsx", kind: "source", platforms: ["ios", "android"] }, + importEdge("file:src/screens/home.tsx", "file:src/components/Button.tsx"), + importEdge("file:src/screens/home.tsx", "file:src/components/Card.tsx"), + importEdge("file:src/screens/home.tsx", "file:src/hooks/useThing.ts"), + importEdge("file:src/screens/home.tsx", "package:react"), + importEdge("file:src/screens/home.tsx", "package:react-native"), + importEdge("file:src/components/Button.tsx", "file:src/util/text.ts"), + ], { "src/screens/home.tsx": 1, "src/components/Button.tsx": 1, "src/components/Card.tsx": 1, "src/hooks/useThing.ts": 1, "src/util/text.ts": 1 }, { react: 2, "react-native": 2 }); +} + +/** + * The keys to toggle so every boundary down to `depth` is open. Opening one hop reveals the next, + * so this repeats until the graph stops growing rather than guessing at the key names. + */ +function openTo(depth: number) { + const toggled = new Set(); + for (let round = 0; round <= depth; round++) { + for (const level of buildFlow(groupedSnapshot(), "screen:home", { toggled }).levels) { + for (const group of level) if (group.collapsed && Number(group.key.split(":")[0]) <= depth) toggled.add(group.key); + } + } + return toggled; +} + +test("a hop opens its first boundary and names the rest", () => { + assert.equal(openByDefault(1, 3), true, "the screen's own source is always open"); + assert.equal(openByDefault(2, 0), true, "and so is the first boundary of every hop below it"); + assert.equal(openByDefault(2, 1), false); + const flow = buildFlow(groupedSnapshot(), "screen:home"); + assert.deepEqual(refsIn(flow.levels[1]), ["file:src/screens/home.tsx"]); + assert.deepEqual(flow.levels[2].map(group => [group.boundary, group.collapsed, group.count]), [["hook", false, 1], ["component", true, 2], ["package", true, 2]]); + assert.deepEqual(refsIn(flow.levels[2]), ["file:src/hooks/useThing.ts"], "one readable spine, not a wall of cards"); + assert.equal(flow.levels[3], undefined, "the boundary left open here imports nothing further"); +}); + +test("buildFlow splits each hop into boundaries, in display order", () => { + const flow = buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(2) }); + assert.deepEqual(refsIn(flow.levels[1]), ["file:src/screens/home.tsx"]); + assert.deepEqual(groupsIn(flow.levels[2]), ["hook", "component", "package"], "boundaries keep the display order, not discovery order"); + assert.deepEqual(flow.levels[2].find(group => group.boundary === "package")?.refs, ["package:react", "package:react-native"]); + assert.deepEqual(refsIn(buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) }).levels[3]), ["file:src/util/text.ts"]); + assert.equal(flow.levels[2][0].key, groupKey(2, "hook")); +}); + +test("folding a boundary drops its rows and everything they would have pulled in", () => { + // openTo flips the folded boundaries open; dropping one key leaves that boundary at its default. + const open = openTo(3); + open.delete(groupKey(2, "component")); + const flow = buildFlow(groupedSnapshot(), "screen:home", { toggled: open }); + const components = flow.levels[2].find(group => group.boundary === "component"); + assert.deepEqual(components?.refs, []); + assert.equal(components?.collapsed, true); + assert.equal(components?.count, 2, "the header still reports what it is hiding"); + assert.equal(flow.levels[3], undefined, "Button.tsx was the only path to the next hop"); + assert.deepEqual(refsIn(flow.levels[2]), ["file:src/hooks/useThing.ts", "package:react", "package:react-native"], "the other boundaries are untouched"); +}); + +test("a boundary past its cap folds the rest into an overflow its key can open", () => { + const snapshot = groupedSnapshot(); + const capped = buildFlow(snapshot, "screen:home", { groupCap: 1, toggled: openTo(3) }); + const components = capped.levels[2].find(group => group.boundary === "component"); + assert.deepEqual(components?.refs, ["file:src/components/Button.tsx"]); + assert.equal(components?.hidden, 1); + assert.deepEqual(refsIn(capped.levels[3]), ["file:src/util/text.ts"], "the row that is drawn still seeds the next hop"); + const opened = buildFlow(snapshot, "screen:home", { groupCap: 1, toggled: openTo(3), expanded: new Set([groupKey(2, "component")]) }); + const all = opened.levels[2].find(group => group.boundary === "component"); + assert.deepEqual(all?.refs, ["file:src/components/Button.tsx", "file:src/components/Card.tsx"]); + assert.equal(all?.hidden, 0); +}); + +test("placeFlow stacks a hop into a column without overlapping its cards", () => { + const placed = placeFlow(buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) })); + assert.equal(placed.cards.length, 8, "it places every drawn row"); + assert.equal(placed.headers.length, 5, "one header per boundary, and none for the root"); + assert.ok(placed.cards.every(card => placed.rectOf.get(card.ref) === card.rect)); + for (let a = 0; a < placed.cards.length; a++) for (let b = a + 1; b < placed.cards.length; b++) { + const first = placed.cards[a].rect, second = placed.cards[b].rect; + const overlaps = first.x < second.x + second.width - 0.001 && second.x < first.x + first.width - 0.001 + && first.y < second.y + second.height - 0.001 && second.y < first.y + first.height - 0.001; + assert.ok(!overlaps, `${placed.cards[a].ref} overlaps ${placed.cards[b].ref}`); + } + for (const card of placed.cards) { + assert.ok(card.rect.x >= 0 && card.rect.y >= 0 && card.rect.x + card.rect.width <= placed.width + 0.001 && card.rect.y + card.rect.height <= placed.height + 0.001, "every card stays inside the board"); + } +}); + +test("each hop steps one column along, anchored so a fold moves nothing before it", () => { + const flow = buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) }); + const columns = placeFlow(flow); + const depthOf = (placed: ReturnType, depth: number) => placed.cards.filter(card => card.depth === depth); + // Rows under a header are indented past it, which the root card has no header to sit under. + assert.equal(columns.headers[0].rect.x - depthOf(columns, 0)[0].rect.x, FLOW_METRICS.column); + assert.equal(depthOf(columns, 1)[0].rect.x - depthOf(columns, 0)[0].rect.x, FLOW_METRICS.column + FLOW_METRICS.indent); + assert.equal(depthOf(columns, 2).every(card => card.rect.x === depthOf(columns, 1)[0].rect.x + FLOW_METRICS.column), true); + assert.equal(depthOf(columns, 0)[0].rect.y, FLOW_METRICS.pad); + const folded = placeFlow(buildFlow(groupedSnapshot(), "screen:home")); + assert.deepEqual(depthOf(folded, 0)[0].rect, depthOf(columns, 0)[0].rect, "the root sits where it sat before the fold"); + assert.deepEqual(depthOf(folded, 1)[0].rect, depthOf(columns, 1)[0].rect); +}); + +test("a folded boundary still records what feeds it, and where its header landed", () => { + const folded = buildFlow(groupedSnapshot(), "screen:home"); + const components = folded.levels[2].find(group => group.boundary === "component"); + assert.deepEqual(components?.sources, ["file:src/screens/home.tsx"], "the connector has somewhere to come from"); + const placed = placeFlow(folded); + assert.ok(placed.headerRectOf.get(groupKey(2, "component")), "and somewhere to land"); + assert.deepEqual(placed.headerRectOf.get(groupKey(2, "component")), placed.headers.find(header => header.key === groupKey(2, "component"))?.rect); + // Sources are the cards above, so a boundary fed by two files records both of them once each. + const deep = buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) }); + assert.deepEqual(deep.levels[3].find(group => group.boundary === "util")?.sources, ["file:src/components/Button.tsx"]); +}); + +test("a link leaves the right edge and lands on the left", () => { + const from = { x: 0, y: 0, width: 100, height: 40 }, to = { x: 200, y: 80, width: 100, height: 40 }; + assert.equal(flowLink(from, to), "M100 20 C145 20 155 100 200 100"); +}); + +test("boundaryOf reads a role off the path", () => { + assert.equal(boundaryOf("screen:home"), "entry", "an analyzer root is an entry point, not necessarily a screen"); + assert.equal(boundaryOf("package:left-pad"), "package"); + assert.equal(boundaryOf("file:src/state/usePostThread.ts"), "hook", "a use-prefixed file is a hook wherever it lives"); + assert.equal(boundaryOf("file:src/hooks/session.ts"), "hook"); + assert.equal(boundaryOf("file:src/lib/api/feed.ts"), "api"); + assert.equal(boundaryOf("file:src/state/queries/post.ts"), "api", "the nearer folder wins over the outer one"); + assert.equal(boundaryOf("file:src/store/index.ts"), "state"); + assert.equal(boundaryOf("file:src/navigation/Root.tsx"), "navigation"); + assert.equal(boundaryOf("file:src/components/Button.tsx"), "component"); + assert.equal(boundaryOf("file:src/view/shell/index.tsx"), "entry"); + assert.equal(boundaryOf("file:src/types/post.d.ts"), "model"); + assert.equal(boundaryOf("file:ios/AppDelegate.swift"), "native"); + assert.equal(boundaryOf("file:assets/icon.png"), "asset"); + assert.equal(boundaryOf("file:src/lib/strings.ts"), "util"); + assert.equal(boundaryOf("file:src/lexicons/getProfile.ts"), "model", "generated schema definitions are not utilities"); + assert.equal(boundaryOf("file:src/weird/thing.ts"), "other", "an unrecognised path says so rather than claiming a role"); + assert.equal(boundaryOf("file:node_modules/@atproto/api/dist/index.js"), "package", "a dependency is a package however the analyzer recorded it"); + assert.equal(boundaryOf("file:node_modules/react-native/Libraries/Image/Image.ios.js"), "package", "a dependency's own folder names never classify it"); +}); + +test("boundariesIn counts in display order and lists only what it was given", () => { + assert.deepEqual(boundariesIn(["hook", "entry", "hook", "model"]), [ + { boundary: "entry", count: 1 }, { boundary: "hook", count: 2 }, { boundary: "model", count: 1 }, + ]); + assert.deepEqual(boundariesIn([]), []); +}); + +test("depth tints differ per hop and settle at the last one", () => { + assert.notEqual(depthHue(0), depthHue(1)); + assert.equal(depthHue(40), depthHue(4)); + assert.match(depthTint(0, 0.2), /^rgba\(\d+,\d+,\d+,0\.2\)$/); +}); + +test("a barrel file is named for the folder it indexes, and keeps its whole path", () => { + assert.equal(labelOf("file:src/components/forms/Toggle/index.tsx"), "Toggle"); + assert.equal(pathOf("file:src/components/forms/Toggle/index.tsx"), "src/components/forms"); + assert.equal(labelOf("file:src/components/Typography.tsx"), "Typography.tsx"); + assert.equal(pathOf("file:src/components/Typography.tsx"), "src/components"); + assert.equal(labelOf("file:index.ts"), "index.ts", "a barrel at the root has no folder to borrow"); + assert.equal(labelOf("package:react-native-screens"), "react-native-screens"); + assert.equal(pathOf("package:react-native-screens"), "node_modules/"); +}); + +test("a long path truncates from the left and a long name from the right", () => { + assert.equal(ellipsize("src/screens/Settings/components", 14), "…gs/components"); + assert.equal(ellipsize("src/alf", 14), "src/alf"); + assert.equal(clampName("ModerationInboxSettings.tsx", 12), "ModerationI…"); + assert.equal(clampName("Toggle", 12), "Toggle"); +}); diff --git a/packages/ui/test/app-intelligence-view.test.ts b/packages/ui/test/app-intelligence-view.test.ts new file mode 100644 index 00000000..77d2c000 --- /dev/null +++ b/packages/ui/test/app-intelligence-view.test.ts @@ -0,0 +1,415 @@ +import test from "node:test"; +import type { TestContext } from "node:test"; +import assert from "node:assert/strict"; +import { build } from "esbuild"; +import { JSDOM } from "jsdom"; +import { mkdtemp, rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { AppIntelSnapshot, IntelEdge } from "@mobile-dev/shared/app-intelligence.ts"; +import { AppIntelligencePanel } from "../src/app-intelligence-panel.ts"; + +function fixture(): AppIntelSnapshot { + const edges: IntelEdge[] = [ + { from: "screen:home", to: "file:app/index.tsx", kind: "source", platforms: ["ios", "android"] }, + { from: "screen:profile", to: "file:app/profile.tsx", kind: "source", platforms: ["ios", "android"] }, + { from: "file:app/index.tsx", to: "file:src/shared.ts", kind: "import", platforms: ["ios", "android"] }, + { from: "file:app/profile.tsx", to: "file:src/shared.ts", kind: "import", platforms: ["ios", "android"] }, + { from: "file:app/profile.tsx", to: "file:src/only.ios.ts", kind: "import", platforms: ["ios"] }, + { from: "file:app/profile.tsx", to: "file:src/components/Button.tsx", kind: "import", platforms: ["ios", "android"] }, + { from: "file:app/profile.tsx", to: "file:src/components/Card.tsx", kind: "import", platforms: ["ios", "android"] }, + { from: "file:app/profile.tsx", to: "file:src/hooks/useThing.ts", kind: "import", platforms: ["ios", "android"] }, + { from: "file:src/components/Button.tsx", to: "file:src/util/text.ts", kind: "import", platforms: ["ios", "android"] }, + { from: "file:src/shared.ts", to: "package:left-pad", kind: "import", platforms: ["ios", "android"] }, + ]; + const indexes: AppIntelSnapshot["indexes"] = { screens: Object.create(null), incoming: Object.create(null), outgoing: Object.create(null) }; + edges.forEach((edge, index) => { (indexes.outgoing[edge.from] ??= []).push(index); if (edge.to) (indexes.incoming[edge.to] ??= []).push(index); }); + indexes.screens.home = ["home"]; + indexes.screens.profile = ["profile"]; + const file = (reach: number) => ({ fingerprint: "x", kind: "source" as const, reach }); + return { + formatVersion: 1, + meta: { projectId: "p", generation: "g", analyzedAt: "2026-11-04T00:00:00Z", analyzerVersion: "t", sourceFingerprint: "f" }, + files: { "app/index.tsx": file(1), "app/profile.tsx": file(1), "src/shared.ts": file(2), "src/only.ios.ts": file(1), "src/components/Button.tsx": file(1), "src/components/Card.tsx": file(1), "src/hooks/useThing.ts": file(1), "src/util/text.ts": file(1) }, + packages: { "left-pad": { name: "left-pad", reach: 2, entry: "node_modules/left-pad/index.js" } }, + screens: { + home: { title: "Home", route: "/", source: "app/index.tsx", layouts: [] }, + profile: { title: "Profile", route: "/profile", source: "app/profile.tsx", layouts: [] }, + }, + edges, diagnostics: [], indexes, + }; +} + +/** Boots a JSDOM environment and the real AppIntelligenceView bundle, with `app` as its host. */ +async function mount(t: TestContext, app: Pick) { + const dom = new JSDOM('
', { pretendToBeVisual: true, url: "http://localhost" }); + const previous = new Map(); + const globals = { + window: dom.window, document: dom.window.document, navigator: dom.window.navigator, + HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, Element: dom.window.Element, Event: dom.window.Event, + // The confirmation popover is a Radix layer: it walks the tree and dispatches its own events, + // and every constructor it touches has to be this window's, not Node's. + ...Object.fromEntries(["CustomEvent", "KeyboardEvent", "MouseEvent", "PointerEvent", "FocusEvent", "DocumentFragment", + "NodeFilter", "DOMRect", "HTMLButtonElement", "HTMLInputElement", "HTMLSelectElement", "HTMLTextAreaElement", "SVGElement", + ].map(name => [name, (dom.window as unknown as Record)[name]])), + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + ResizeObserver: class { observe() {} unobserve() {} disconnect() {} }, + IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const [key, value] of Object.entries(globals)) { + previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { value, configurable: true, writable: true }); + } + // The panel measures its own box; JSDOM has no layout, so supply one size for every element. + Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { get: () => 640, configurable: true }); + Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { get: () => 360, configurable: true }); + + const directory = await mkdtemp(resolve("node_modules/.mobile-dev-app-intel-view-")); + let unmount = async () => {}; + t.after(async () => { + await unmount(); + dom.window.close(); + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + await rm(directory, { recursive: true, force: true }); + }); + + const output = resolve(directory, "view.mjs"); + await build({ + stdin: { contents: 'export { AppIntelligenceView } from "./packages/ui/src/components/app-intelligence-view.tsx";', resolveDir: process.cwd(), loader: "ts" }, + outfile: output, bundle: true, format: "esm", platform: "node", jsx: "automatic", + external: ["react", "react/*", "react-dom", "react-dom/*", "radix-ui", "lucide-react", "@base-ui/react/*"], + }); + const { AppIntelligenceView } = await import(pathToFileURL(output).href); + const { act, createElement } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + const panel = new AppIntelligencePanel(app as unknown as App); + const root = createRoot(dom.window.document.getElementById("root")!); + unmount = async () => { await act(async () => { root.unmount(); }); }; + await act(async () => { root.render(createElement(AppIntelligenceView, { panel })); }); + return { dom, act, panel }; +} + +test("the project overview filters the right rail from its connected area nodes", async t => { + const snapshot = fixture(); + const source = "src/components/Settings.tsx"; + snapshot.screens.settings = { title: "Settings", route: "/settings", source, layouts: [] }; + snapshot.files[source] = { fingerprint: "x", kind: "source", reach: 1 }; + const edge = snapshot.edges.push({ from: "screen:settings", to: `file:${source}`, kind: "source", platforms: ["ios", "android"] }) - 1; + snapshot.indexes.screens.settings = ["settings"]; + snapshot.indexes.outgoing["screen:settings"] = [edge]; + snapshot.indexes.incoming[`file:${source}`] = [edge]; + const app = { async callServerTool({ name }: { name: string }) { + if (name === "mobile_app_intel_snapshot") return { content: [], structuredContent: { projectPath: "/tmp/social-app", snapshot } }; + return { content: [], structuredContent: {} }; + } }; + const { dom, act, panel } = await mount(t, app); + await act(async () => { panel.setAvailable(true); panel.show(); }); + await act(async () => { await new Promise(done => setTimeout(done, 0)); }); + + const overview = dom.window.document.querySelector("[data-project-overview]") as HTMLElement; + assert.ok(overview, "the generated graph starts with the project overview"); + assert.match((overview.querySelector("[data-project-node]") as HTMLElement).textContent ?? "", /social-app/); + assert.match((overview.querySelector("[data-project-node]") as HTMLElement).textContent ?? "", /3 entry points/); + const areas = [...overview.querySelectorAll("[data-area-node]")]; + assert.deepEqual(areas.map(area => area.dataset.areaNode), ["component", "other"]); + assert.match(areas[0].textContent ?? "", /1 entry/); + assert.match(areas[1].textContent ?? "", /2 entries/); + assert.doesNotMatch(overview.textContent ?? "", /screens?/i, "the overview consistently calls them entries"); + assert.equal(areas[0].style.animationDelay, "2s", "the first click prompt waits two seconds"); + assert.equal(areas[1].style.animationDelay, "2.35s", "the prompts are staggered around the project"); + assert.equal(overview.querySelectorAll("[data-area-connector]").length, 2, "each area is connected to the project"); + const orbits = [...overview.querySelectorAll("[data-project-orbit]")]; + assert.equal(orbits.length, 3, "concentric pulses surround the project node"); + assert.deepEqual(orbits.map(orbit => orbit.style.animationDelay), ["0s", "0.8s", "1.6s"], "the rings expand from the centre in sequence"); + + await act(async () => { areas[0].click(); }); + assert.deepEqual(panel.getSnapshot().boundaries, ["component"], "clicking an area applies it as the sole filter"); + assert.equal(panel.getSnapshot().selectedScreen, null, "filtering leaves screen selection to the right-hand list"); + assert.equal(dom.window.document.querySelector('[data-area-node="component"]')?.getAttribute("aria-pressed"), "true"); + const railToggle = dom.window.document.querySelector('aside button[aria-controls="app-intel-entries"]') as HTMLButtonElement; + assert.equal(railToggle.getAttribute("aria-expanded"), "true", "the matching list opens automatically"); + const entries = [...dom.window.document.querySelectorAll("aside [data-entry]")]; + assert.deepEqual(entries.map(entry => entry.dataset.entry), ["settings"]); + + const trigger = dom.window.document.querySelector("[data-flow-filter-trigger]") as HTMLButtonElement; + assert.match(trigger.textContent ?? "", /1/, "the applied choice remains visible as an active filter"); + await act(async () => { trigger.click(); }); + assert.equal((dom.window.document.querySelector('[data-flow-filter] [data-boundary="component"]') as HTMLInputElement).checked, true); + assert.equal((dom.window.document.querySelector('[data-flow-filter] [data-boundary="other"]') as HTMLInputElement).checked, false); + await act(async () => { trigger.click(); await new Promise(done => setTimeout(done, 0)); }); +}); + +test("App Intelligence traces a screen, groups each hop by boundary and folds a group away", async t => { + const snapshot = fixture(); + const calls: Array<{ name: string; arguments?: Record }> = []; + const recents = ["/tmp/social-app", "/tmp/other-app"]; + const app = { async callServerTool({ name, arguments: args }: { name: string; arguments?: Record }) { + calls.push({ name, arguments: args }); + if (name === "mobile_app_intel_snapshot") return { content: [], structuredContent: { projectPath: "/tmp/social-app", snapshot, recents } }; + if (name === "mobile_select_app_intel_project") return { content: [], structuredContent: { projectPath: args?.projectPath, snapshot, recents } }; + if (name === "mobile_clear_app_intel") return { content: [], structuredContent: { projectPath: args?.projectPath, snapshot: null, recents } }; + return { content: [], structuredContent: {} }; + } }; + const { dom, act, panel } = await mount(t, app); + await act(async () => { panel.setAvailable(true); panel.show(); }); + await act(async () => { await new Promise(done => setTimeout(done, 0)); }); + + const query = (selector: string) => dom.window.document.querySelectorAll(selector); + const refs = () => [...query("[data-flow-node]")].map(node => node.getAttribute("data-flow-node")); + const railEntries = () => [...query("aside [data-entry]")].map(entry => entry.getAttribute("data-entry")); + const groups = () => [...query("[data-flow-group]")].map(node => node.getAttribute("data-flow-group")); + assert.ok(dom.window.document.getElementById("app-intelligence-panel"), "the panel keeps the id the package smoke test asserts"); + const emptyFlow = dom.window.document.querySelector("[data-flow-empty]") as HTMLElement; + assert.ok(emptyFlow, "the canvas starts with an empty prompt before an entry point is chosen"); + assert.equal(emptyFlow.style.width, "", "the empty prompt does not feed a measured width back into its scroll container"); + assert.equal(emptyFlow.style.height, "", "the empty prompt does not feed a measured height back into its scroll container"); + + // The picker is the workspace's own Select, showing the project by folder name. Its menu is a + // popup the panel opens, so the switch itself is driven through the panel the trigger calls. + const picker = () => dom.window.document.querySelector("[data-project-picker]") as HTMLElement; + assert.equal(picker().getAttribute("data-slot"), "select-trigger"); + assert.match(picker().textContent ?? "", /social-app/); + await act(async () => { await panel.selectProject("/tmp/other-app"); }); + assert.deepEqual(calls.at(-1), { name: "mobile_select_app_intel_project", arguments: { projectPath: "/tmp/other-app" } }); + assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app"); + assert.match(picker().textContent ?? "", /other-app/, "and the trigger follows the choice"); + + // The rail lists entry points, but starts collapsed so the Flow gets the width. + assert.equal(refs().length, 0, "no screen is selected yet"); + assert.equal(dom.window.document.getElementById("app-intel-entries"), null, "the rail is collapsed by default"); + const railToggle = dom.window.document.querySelector('aside button[aria-controls="app-intel-entries"]') as HTMLButtonElement; + assert.equal(railToggle.getAttribute("aria-expanded"), "false"); + await act(async () => { railToggle.click(); }); + assert.equal(railToggle.getAttribute("aria-expanded"), "true"); + const rail = dom.window.document.querySelector("#app-intel-entries") as HTMLElement; + assert.ok(query("[data-flow-filter-trigger]").length === 1, "the entry-point filter is there before anything is traced"); + // The checkboxes live in the filter's dropdown, closed by default; open it once for the rest. + await act(async () => { (dom.window.document.querySelector("[data-flow-filter-trigger]") as HTMLButtonElement).click(); }); + assert.ok(query("[data-flow-filter]").length === 1, "opening the trigger mounts the dropdown"); + const screenButton = [...rail.querySelectorAll("button")].find(button => button.textContent?.includes("Profile")); + assert.ok(screenButton, "the rail lists screens"); + await act(async () => { screenButton.click(); }); + assert.deepEqual(refs(), ["screen:profile", "file:app/profile.tsx", "file:src/hooks/useThing.ts"], "the screen, its source and the first boundary under it"); + + // Each hop is split into boundary headers, and only the first opens by itself. + assert.deepEqual(groups(), ["1:other", "2:hook", "2:component", "2:other"], "every boundary is named, open or not"); + const group = (key: string) => dom.window.document.querySelector(`[data-flow-fold="${key}"]`) as Element; + assert.equal(group("2:component").getAttribute("aria-expanded"), "false"); + await act(async () => { group("2:component").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.deepEqual(panel.getSnapshot().toggled, ["2:component"]); + assert.equal(group("2:component").getAttribute("aria-expanded"), "true"); + assert.ok(refs().includes("file:src/components/Button.tsx"), "opening the boundary draws its rows"); + assert.deepEqual(groups().filter(key => key?.startsWith("3:")), ["3:util"], "and the hop under them, still folded"); + await act(async () => { group("2:component").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.deepEqual(panel.getSnapshot().toggled, [], "clicking it again folds it back to the default"); + assert.ok(!refs().includes("file:src/components/Button.tsx")); + + // Folding hides cards, never the shape: the connector moves onto the header instead. + const links = () => [...query("[data-flow-link]")].map(link => link.getAttribute("stroke-opacity")); + const foldedLinks = () => [...query("[data-flow-folded-link]")].map(link => link.getAttribute("data-flow-folded-link")); + assert.ok(foldedLinks().includes("2:component"), "a folded boundary keeps a connector into its header"); + assert.deepEqual([...new Set([...links(), ...[...query("[data-flow-folded-link]")].map(link => link.getAttribute("stroke-opacity"))])], ["0.2"], "everything is dim until something is lit"); + + // The pin on a header lights that one hop's boundary; the dot on a chip lights it at every hop. + const litOpacity = (key: string) => [...query(`[data-flow-folded-link="${key}"]`)].map(link => link.getAttribute("stroke-opacity")); + await act(async () => { (dom.window.document.querySelector('[data-flow-light="2:component"]') as Element).dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(panel.getSnapshot().litBoundary, "2:component"); + assert.deepEqual(litOpacity("2:component"), ["1"]); + assert.deepEqual(litOpacity("2:other"), ["0.2"], "the other folded boundaries stay dim"); + await act(async () => { (dom.window.document.querySelector('[data-flow-light="2:component"]') as Element).dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(panel.getSnapshot().litBoundary, null, "clicking the pin again dims it again"); + assert.deepEqual(litOpacity("2:component"), ["0.2"]); + const chipDot = dom.window.document.querySelector('[data-boundary-light="other"]') as HTMLButtonElement; + assert.ok(chipDot, "the highlight dot lives in the open dropdown"); + await act(async () => { chipDot.click(); }); + assert.equal(panel.getSnapshot().litBoundary, "*:other"); + assert.equal(chipDot.getAttribute("aria-pressed"), "true"); + assert.deepEqual(litOpacity("2:other"), ["1"], "a chip lights its boundary wherever it appears"); + assert.deepEqual(railEntries().sort(), ["home", "profile"], "and lighting it does not filter the rail"); + await act(async () => { chipDot.click(); }); + + // An overflow is a control, not a label: clicking it opens the rest of that boundary. + await act(async () => { panel.showAllIn("2:component"); }); + assert.deepEqual(panel.getSnapshot().expanded, ["2:component"]); + + // The filter is the entry-point rail's filter: unticking a boundary takes those entries out of + // the rail without touching the traced graph. + const chips = () => [...query("[data-flow-filter] [data-boundary]")].map(chip => chip.getAttribute("data-boundary")); + assert.deepEqual(chips(), ["all", "other"], "both fixture entries render plain source files"); + assert.deepEqual(railEntries().sort(), ["home", "profile"]); + const other = dom.window.document.querySelector('[data-flow-filter] [data-boundary="other"]') as HTMLInputElement; + await act(async () => { other.click(); }); + assert.deepEqual(railEntries(), [], "unticking the only boundary empties the rail"); + assert.ok(refs().length > 0, "the filter is the rail's, so the traced graph still draws in full"); + await act(async () => { (dom.window.document.querySelector('[data-flow-filter] [data-boundary="all"]') as HTMLInputElement).click(); }); + assert.deepEqual(railEntries().sort(), ["home", "profile"], "All brings them back"); + + // Dragging pans the board itself, which is what a scroll-based pan failed to do when the graph + // already fitted its pane. The drag must not re-root the graph on the card underneath. + const canvas = dom.window.document.querySelector("[data-flow-canvas]") as HTMLElement; + const board = canvas.firstElementChild as HTMLElement; + const pointer = (type: string, x: number, y: number, target: EventTarget) => + target.dispatchEvent(new dom.window.MouseEvent(type, { clientX: x, clientY: y, bubbles: true, button: 0 })); + assert.equal(board.style.transform, "translate(0px, 0px) scale(1)"); + await act(async () => { + pointer("pointerdown", 300, 200, canvas); + pointer("pointermove", 220, 170, dom.window); + pointer("pointerup", 220, 170, dom.window); + }); + assert.equal(board.style.transform, "translate(-80px, -30px) scale(1)", "the board follows the pointer"); + const folds = panel.getSnapshot().toggled.length; + await act(async () => { group("2:other").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(panel.getSnapshot().toggled.length, folds, "the click that ends a drag is swallowed"); + // Folding a boundary is a re-render, and must not move the board the reader panned. + await act(async () => { group("2:other").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(board.style.transform, "translate(-80px, -30px) scale(1)", "a fold leaves the view where it was"); + await act(async () => { canvas.dispatchEvent(new dom.window.MouseEvent("dblclick", { bubbles: true })); }); + assert.equal(board.style.transform, "translate(0px, 0px) scale(1)", "double-click resets"); + + // Zoom is the buttons and the readout beside them, on the same transform as the pan. + const zoom = (which: string) => dom.window.document.querySelector(`[data-zoom="${which}"]`) as HTMLButtonElement; + const level = () => (dom.window.document.querySelector("[data-zoom-level]") as HTMLElement).textContent; + assert.equal(level(), "100%"); + await act(async () => { zoom("in").click(); }); + assert.equal(level(), "125%"); + assert.match(board.style.transform, /scale\(1\.25\)/); + await act(async () => { zoom("out").click(); zoom("out").click(); }); + assert.equal(level(), "80%"); + await act(async () => { for (let press = 0; press < 12; press++) zoom("out").click(); }); + assert.equal(level(), "30%", "zooming out stops at the floor"); + await act(async () => { zoom("reset").click(); }); + assert.equal(board.style.transform, "translate(0px, 0px) scale(1)", "reset returns both pan and zoom"); + + // A card is not a control: clicking one leaves the graph rooted on the screen the rail picked. + await act(async () => { group("2:component").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + const card = [...query("[data-flow-node]")].find(node => node.getAttribute("data-flow-node") === "file:src/components/Button.tsx") as Element; + assert.equal(card.getAttribute("role"), "img", "and it is not announced as one either"); + await act(async () => { card.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(refs()[0], "screen:profile", "the root is still the screen"); + assert.equal(panel.getSnapshot().selectedScreen, "profile"); + + // Picking another screen is the only way to re-root, and it forgets the folds of the old one. + const home = [...rail.querySelectorAll("button")].find(button => button.textContent?.includes("Home")) as HTMLButtonElement; + await act(async () => { home.click(); }); + assert.equal(refs()[0], "screen:home"); + assert.deepEqual(panel.getSnapshot().toggled, [], "the folds belonged to the old screen"); + assert.deepEqual(panel.getSnapshot().expanded, []); + + // The whole right bar can be hidden too, giving the Flow the full width back. + const asideToggle = dom.window.document.querySelector("[data-aside-toggle]") as HTMLButtonElement; + assert.equal(asideToggle.getAttribute("aria-pressed"), "true"); + await act(async () => { asideToggle.click(); }); + assert.equal(dom.window.document.querySelector("aside"), null, "hiding it removes the whole bar, not just its list"); + assert.equal(asideToggle.getAttribute("aria-pressed"), "false"); + await act(async () => { asideToggle.click(); }); + assert.ok(dom.window.document.querySelector("aside"), "and it comes back"); + + // Clear asks first, then throws the saved graph away but keeps the project selected. + const clear = () => dom.window.document.getElementById("app-intel-clear") as HTMLButtonElement | null; + assert.ok(clear(), "Clear sits beside Regenerate while there is a graph"); + const before = calls.length; + await act(async () => { clear()?.click(); }); + assert.equal(calls.length, before, "pressing it only opens the confirmation"); + const confirm = dom.window.document.querySelector("[data-confirm]") as HTMLButtonElement; + assert.ok(confirm, "which offers Cancel and Clear"); + await act(async () => { confirm.click(); }); + assert.deepEqual(calls.at(-1), { name: "mobile_clear_app_intel", arguments: { projectPath: "/tmp/other-app" } }); + assert.equal(panel.getSnapshot().snapshot, null); + assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app", "the project stays selected"); + assert.equal(clear(), null, "and there is nothing left to clear"); + assert.match(dom.window.document.querySelector("header")?.textContent ?? "", /Generate/); +}); + +test("reopening the panel shows a loading state instead of flashing the empty project picker", async t => { + const snapshot = fixture(); + // A reopened panel is a fresh instance: it remembers nothing until this call resolves, so a + // real delay is needed to observe the in-between state the way a slow host round trip would. + const app = { async callServerTool({ name }: { name: string }) { + if (name === "mobile_app_intel_snapshot") { + await new Promise(done => setTimeout(done, 20)); + return { content: [], structuredContent: { projectPath: "/tmp/social-app", snapshot, recents: ["/tmp/social-app"] } }; + } + return { content: [], structuredContent: {} }; + } }; + const { dom, act, panel } = await mount(t, app); + const text = () => dom.window.document.getElementById("app-intelligence-panel")?.textContent ?? ""; + + await act(async () => { panel.setAvailable(true); panel.show(); }); + assert.equal(panel.getSnapshot().busy, "loading", "the load just started"); + assert.match(text(), /Loading/, "a neutral loading state shows while a remembered project reloads"); + assert.doesNotMatch(text(), /Map a React Native app/, "not the empty state, as if nothing had ever been chosen"); + + await act(async () => { await new Promise(done => setTimeout(done, 30)); }); + assert.equal(panel.getSnapshot().busy, null); + assert.match(text(), /social-app/, "the remembered project's graph lands once the load resolves"); +}); + +test("re-showing a panel that already has a graph reloads quietly, with no disabled flash", async t => { + const snapshot = fixture(); + let calls = 0; + const app = { async callServerTool({ name }: { name: string }) { + if (name === "mobile_app_intel_snapshot") { + calls++; + await new Promise(done => setTimeout(done, 20)); + return { content: [], structuredContent: { projectPath: "/tmp/social-app", snapshot, recents: ["/tmp/social-app"] } }; + } + return { content: [], structuredContent: {} }; + } }; + const { dom, act, panel } = await mount(t, app); + const generateButton = () => [...dom.window.document.querySelectorAll("header button")].find(button => /Generate|Regenerate/.test(button.textContent ?? "")) as HTMLButtonElement | undefined; + const generate = () => generateButton()?.textContent ?? ""; + const generateDisabled = () => generateButton()?.disabled; + + // The first open has nothing cached, so it is the one case that still shows a loading state. + await act(async () => { panel.setAvailable(true); panel.show(); }); + await act(async () => { await new Promise(done => setTimeout(done, 30)); }); + assert.equal(calls, 1); + assert.match(generate(), /Regenerate/, "a graph is already loaded"); + + // Simulating a tool-switch away and back: the view remounts and calls show() again, but this + // time a graph is already cached, so it must not flash "Loading" or disable the controls. + await act(async () => { panel.hide(); }); + await act(async () => { panel.show(); }); + assert.equal(panel.getSnapshot().busy, null, "no disabled-controls flash while the cached graph is still shown"); + assert.equal(generateDisabled(), false, "Regenerate stays clickable during the quiet reload"); + assert.doesNotMatch(dom.window.document.getElementById("app-intelligence-panel")?.textContent ?? "", /Loading App Intelligence/); + await act(async () => { await new Promise(done => setTimeout(done, 30)); }); + assert.equal(calls, 2, "the graph is still refreshed in the background"); + assert.equal(panel.getSnapshot().busy, null); +}); + +test("a quiet reload cannot overwrite a newer project selection", async t => { + const snapshot = fixture(); + let snapshotCalls = 0; + let releaseStale!: () => void; + const stale = new Promise(resolve => { releaseStale = resolve; }); + const app = { async callServerTool({ name, arguments: args }: { name: string; arguments?: Record }) { + if (name === "mobile_app_intel_snapshot") { + snapshotCalls++; + if (snapshotCalls === 2) await stale; + return { content: [], structuredContent: { projectPath: "/tmp/social-app", snapshot, recents: ["/tmp/social-app", "/tmp/other-app"] } }; + } + if (name === "mobile_select_app_intel_project") { + return { content: [], structuredContent: { projectPath: args?.projectPath, snapshot, recents: ["/tmp/other-app", "/tmp/social-app"] } }; + } + return { content: [], structuredContent: {} }; + } }; + const { act, panel } = await mount(t, app); + + await act(async () => { panel.setAvailable(true); panel.show(); await new Promise(done => setTimeout(done, 0)); }); + assert.equal(panel.getSnapshot().projectPath, "/tmp/social-app"); + await act(async () => { panel.hide(); panel.show(); }); + await act(async () => { await panel.selectProject("/tmp/other-app"); }); + assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app"); + + releaseStale(); + await act(async () => { await stale; await new Promise(done => setTimeout(done, 0)); }); + assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app", "the late response from the previous project is ignored"); +}); diff --git a/packages/ui/test/react-ui.test.ts b/packages/ui/test/react-ui.test.ts index 475e64d1..8e8d08f6 100644 --- a/packages/ui/test/react-ui.test.ts +++ b/packages/ui/test/react-ui.test.ts @@ -10,6 +10,7 @@ import { createDeviceApps } from "../../test-support/device-apps-fixtures.ts"; import { PerformancePanel } from "../src/performance-panel.ts"; import { StoragePanel } from "../src/storage-panel.ts"; import { TraceSiftPanel } from "../src/tracesift-panel.ts"; +import { AppIntelligencePanel } from "../src/app-intelligence-panel.ts"; import { RecordingController } from "../src/recording-controller.ts"; import type { AppFlowPanel } from "../src/app-flow-panel.ts"; import { WorkspaceTools } from "../src/workspace-tools.ts"; @@ -116,6 +117,7 @@ test("React log controls filter virtual rows, attach full logs, and preserve sim const storage = new StoragePanel(storageApp as unknown as App, deviceApps); storage.setAvailable(true); const tracesift = new TraceSiftPanel(storageApp as unknown as App); + const appIntel = new AppIntelligencePanel(storageApp as unknown as App); const recordingController = new RecordingController(performanceApp as unknown as App); const appFlowSnapshot = { open: false }; const appFlow = { @@ -124,7 +126,7 @@ test("React log controls filter virtual rows, attach full logs, and preserve sim show() {}, hide() {}, }; - const panels = new WorkspaceTools({ context: context as PanelContext, deviceApps, logs: panel, performance, storage, tracesift, "app-flow": appFlow as unknown as AppFlowPanel }); + const panels = new WorkspaceTools({ context: context as PanelContext, deviceApps, logs: panel, performance, storage, "app-intel": appIntel, tracesift, "app-flow": appFlow as unknown as AppFlowPanel }); deviceApps.selectDevice({ udid: UDID, name: "iPhone", state: "Booted", runtime: "iOS" }); performance.setAvailable(true); deviceApps.setAvailable(true); diff --git a/packages/ui/test/ui-telemetry.test.ts b/packages/ui/test/ui-telemetry.test.ts index 686ed80a..69ba8a6d 100644 --- a/packages/ui/test/ui-telemetry.test.ts +++ b/packages/ui/test/ui-telemetry.test.ts @@ -724,3 +724,49 @@ test("UI telemetry attributes each call to the host that renders the page", asyn } } }); + +test("App Intelligence layout measurements carry counts only, never paths", async t => { + const root = process.cwd(); + const built = await build({ + stdin: { contents: 'export * from "./packages/ui/src/telemetry.ts";', resolveDir: root, loader: "ts" }, + bundle: true, write: false, format: "iife", globalName: "Telemetry", platform: "browser", target: "chrome120", + define: { "process.env.NODE_ENV": '"production"' }, + }); + const html = `${identityMeta}`; + const dom = new JSDOM(html, { pretendToBeVisual: true, runScripts: "outside-only", url: "https://mobile-dev.test/" }); + t.after(() => dom.window.close()); + const window = dom.window; + window.Math.random = () => 0; + const bodies: string[] = []; + window.fetch = async (_url, options) => { bodies.push(String(options?.body ?? "")); return new Response("", { status: 200 }); }; + Object.defineProperty(window.performance, "getEntriesByType", { value: () => [] }); + Object.defineProperty(window.performance, "getEntries", { value: () => [] }); + window.eval(built.outputFiles[0].text); + const api = window.Telemetry; + api.startUiTelemetry({ async callServerTool() { return { content: [] }; } }); + api.setUiSurface("app-intel"); + api.recordUiTiming("ui.app_intel.flow_layout", 4); + api.setUiGauge("ui.app_intel.flow_nodes", 37); + api.countUiEvent("ui.app_intel.group_collapse"); + api.countUiEvent("ui.app_intel.group_expand"); + api.countUiEvent("ui.app_intel.group_show_all"); + api.countUiEvent("ui.app_intel.boundary_highlight"); + api.countUiEvent("ui.app_intel.project_switch"); + api.countUiEvent("ui.app_intel.clear"); + api.countUiEvent("ui.app_intel.boundary_filter"); + await api.stopUiTelemetry(); + + const encoded = bodies.join("\n"); + contains(encoded, "ui.app_intel.flow_layout.mean"); + contains(encoded, "ui.app_intel.flow_nodes"); + contains(encoded, "ui.app_intel.group_collapse"); + contains(encoded, "ui.app_intel.group_expand"); + contains(encoded, "ui.app_intel.group_show_all"); + contains(encoded, "ui.app_intel.boundary_highlight"); + contains(encoded, "ui.app_intel.project_switch"); + contains(encoded, "ui.app_intel.clear"); + contains(encoded, "ui.app_intel.boundary_filter"); + contains(encoded, "app-intel"); + contains(encoded, "/Users/", false); + contains(encoded, ".tsx", false); +}); diff --git a/test-codex-local.sh b/test-codex-local.sh new file mode 100755 index 00000000..f6988f14 --- /dev/null +++ b/test-codex-local.sh @@ -0,0 +1,115 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL=0 +FULL_CHECK=0 +NO_LAUNCH=0 + +usage() { + echo "Usage: $0 [--install] [--full] [--no-launch]" >&2 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --install) INSTALL=1 ;; + --full) FULL_CHECK=1 ;; + --no-launch) NO_LAUNCH=1 ;; + *) usage; exit 2 ;; + esac + shift +done + +cd "$ROOT" + +node -e ' + const [major, minor] = process.versions.node.split(".").map(Number); + if (major < 22 || (major === 22 && minor < 19)) { + console.error(`Node.js 22.19.0 or newer is required; found ${process.version}.`); + process.exit(1); + } +' + +if [[ $INSTALL -eq 1 || ! -d node_modules ]]; then + npm ci +fi + +if [[ ! -x packages/native/vendor/baguette/Baguette ]]; then + npm run vendor:baguette +fi +if [[ ! -f packages/runtimes/agent-device/node_modules/agent-device/package.json ]]; then + npm run vendor:agent-device +fi +if [[ ! -d packages/runtimes/serve-emu/node_modules ]]; then + npm run vendor:serve-emu +fi + +npm run build +node --test \ + packages/core/test/app-intelligence.test.ts \ + packages/core/test/browser-panel.test.ts \ + packages/core/test/plugin.test.ts \ + packages/core/test/telemetry.test.ts +npm run package +npm run test:codex-plugin + +# Codex runs the packaged copy, so catch a stale bundle before installing it. +for tool in mobile_generate_app_intel mobile_app_intel mobile_open_app_intel; do + if ! grep -q "$tool" release/marketplace/plugins/mobile-dev/dist/server.mjs; then + echo "release bundle is missing $tool; the build did not include App Intelligence" >&2 + exit 1 + fi +done + +if [[ ! -f release/marketplace/plugins/mobile-dev/dist/app-intelligence-worker.mjs ]]; then + echo "release bundle is missing the App Intelligence worker" >&2 + exit 1 +fi + +if [[ $FULL_CHECK -eq 1 ]]; then + npm run test:package +fi + +MARKETPLACE_ROOT="$ROOT/release/marketplace" +MARKETPLACES_JSON="$(codex plugin marketplace list --json)" +CONFIGURED_ROOT="$( + printf '%s' "$MARKETPLACES_JSON" | + node -e ' + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", chunk => { input += chunk; }); + process.stdin.on("end", () => { + const entry = JSON.parse(input).marketplaces?.find(value => value.name === "mobile-dev-local"); + process.stdout.write(entry?.root ?? ""); + }); + ' +)" + +if [[ -z "$CONFIGURED_ROOT" ]]; then + codex plugin marketplace add "$MARKETPLACE_ROOT" +elif [[ "$CONFIGURED_ROOT" != "$MARKETPLACE_ROOT" ]]; then + echo "Repointing mobile-dev-local from $CONFIGURED_ROOT to this worktree." + codex plugin marketplace remove mobile-dev-local + codex plugin marketplace add "$MARKETPLACE_ROOT" +fi + +codex plugin add mobile-dev@mobile-dev-local --json + +APP_NAME="${CODEX_DESKTOP_APP:-Codex}" +VERSION="$(node -p 'require("./package.json").version')" +echo +echo "Mobile Dev $VERSION from this App Intelligence worktree is installed." + +if [[ $NO_LAUNCH -eq 1 || ! -t 0 ]]; then + echo "Fully quit the desktop app, then run:" + printf 'open -na %q\n' "$APP_NAME" + exit 0 +fi + +echo "Fully quit $APP_NAME with Cmd-Q before continuing." +read -r -p "Press Enter to relaunch it... " +open -na "$APP_NAME" + +echo "Open a new chat, open Mobile Dev, choose App Intelligence from the feature menu, select a React Native project, and generate its graph." +echo "In chat, also try: Show me the App Intelligence for this app." +echo "Then ask: What are the dependencies of ?" diff --git a/tooling/host-runtime.mjs b/tooling/host-runtime.mjs index ed4d75a9..f1d74d59 100644 --- a/tooling/host-runtime.mjs +++ b/tooling/host-runtime.mjs @@ -12,7 +12,7 @@ export const RELEASE_DOCUMENTS = ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.m export const PANEL_PARTS = ["baguette", "serve-emu", "ios-mirror", "ios-mirror-service.mjs", "resource-saving"]; const RUNTIME_FILES = [ - "server.mjs", "ios-mirror-service.mjs", "app.html", "browser-panel.html", "baguette/Baguette", "baguette/Baguette_Baguette.bundle", + "server.mjs", "app-intelligence-worker.mjs", "ios-mirror-service.mjs", "app.html", "browser-panel.html", "baguette/Baguette", "baguette/Baguette_Baguette.bundle", "ios-fps/mobile-dev-ios-fps", ...["arm64-v8a", "armeabi-v7a", "x86", "x86_64"].map(abi => `android-fps/${abi}/mobile-dev-fps`), "ios-mirror/darwin-arm64.node", "ios-mirror/third-party-licenses.txt", "ios-logs/mobile-dev-ios-logs", "ios-logs/third-party-licenses.txt", diff --git a/tooling/hosts/hosts.spec.ts b/tooling/hosts/hosts.spec.ts index c1ee7be7..4772ea92 100644 --- a/tooling/hosts/hosts.spec.ts +++ b/tooling/hosts/hosts.spec.ts @@ -58,7 +58,7 @@ test.describe("MCP Apps hosts", () => { test.skip(hasPanel(server) === false, "This target's server renders no panel in the chat."); const { view } = await openView("mobile_open_workspace"); await expect(view.locator("#tool-select")).toContainText("Logs"); - for (const label of ["Performance", "Storage", "TraceSift", "Logs"]) { + for (const label of ["Performance", "Storage", "App Intel", "TraceSift", "Logs"]) { await view.locator("#tool-select").click(); await view.getByRole("option", { name: label }).click(); await expect(view.locator("#tool-select")).toContainText(label); diff --git a/tooling/hosts/screenshots/codex/workspace-app-intel-dark.png b/tooling/hosts/screenshots/codex/workspace-app-intel-dark.png new file mode 100644 index 00000000..0828ce83 Binary files /dev/null and b/tooling/hosts/screenshots/codex/workspace-app-intel-dark.png differ diff --git a/tooling/hosts/screenshots/codex/workspace-app-intel-light.png b/tooling/hosts/screenshots/codex/workspace-app-intel-light.png new file mode 100644 index 00000000..36d7bb9c Binary files /dev/null and b/tooling/hosts/screenshots/codex/workspace-app-intel-light.png differ diff --git a/tooling/hosts/screenshots/codex/workspace-menu-over-performance-light.png b/tooling/hosts/screenshots/codex/workspace-menu-over-performance-light.png index f28d9fd1..eba150a8 100644 Binary files a/tooling/hosts/screenshots/codex/workspace-menu-over-performance-light.png and b/tooling/hosts/screenshots/codex/workspace-menu-over-performance-light.png differ diff --git a/tooling/hosts/visual.spec.ts b/tooling/hosts/visual.spec.ts index 4c2c6ff1..352f2955 100644 --- a/tooling/hosts/visual.spec.ts +++ b/tooling/hosts/visual.spec.ts @@ -8,7 +8,7 @@ import type { Theme } from "./profiles.ts"; // match CI: refresh them with `npm run test:visual:docker -- --update-snapshots`, then review the diff. const themes: Theme[] = ["light", "dark"]; -const tools = [["performance", "Performance"], ["storage", "Storage"], ["tracesift", "TraceSift"]] as const; +const tools = [["performance", "Performance"], ["storage", "Storage"], ["app-intel", "App Intel"], ["tracesift", "TraceSift"]] as const; for (const theme of themes) test.describe(`${theme}`, { tag: "@visual" }, () => { test(`workspace`, async ({ openView }) => { diff --git a/tooling/server-bundle.mjs b/tooling/server-bundle.mjs index cf22cfe2..cf1fac67 100644 --- a/tooling/server-bundle.mjs +++ b/tooling/server-bundle.mjs @@ -25,8 +25,11 @@ export const serverBundleBanner = [ * dynamically become chunks beside it, found the same way. */ export async function buildServerBundles({ entryPoints, outdir, sentryDirectory, splitting = false }) { + const serverEntries = entryPoints.server && !entryPoints["app-intelligence-worker"] + ? { ...entryPoints, "app-intelligence-worker": "packages/core/src/app-intelligence/worker.ts" } + : entryPoints; const result = await build({ - entryPoints, outdir, outExtension: { ".js": ".mjs" }, bundle: true, splitting, + entryPoints: serverEntries, outdir, outExtension: { ".js": ".mjs" }, bundle: true, splitting, // typescript.js is a single ~9.9 MB module, which no amount of splitting can bring under // Claude's 5 MiB per-file limit. App-flow's sources keep importing "typescript" so tsc // still sees the real declarations; at runtime this shim resolves the compiler from the