From 07d9802d0f40e9760e2489a1c62c98c4d48f33c9 Mon Sep 17 00:00:00 2001
From: Hur Ali
Date: Thu, 5 Nov 2026 10:02:41 +0500
Subject: [PATCH 1/9] feat: add app intel
---
apps/claude/skills/mobile-dev/SKILL.md | 4 +
apps/codex-plugin/tooling/smoke-package.mjs | 2 +-
apps/mac/Sources/MobileDev/Tool.swift | 3 +
apps/mac/web/mac-workspace.tsx | 2 +
docs/telemetry.md | 17 +-
package-lock.json | 211 ++++++++--
package.json | 2 +
.../core/src/app-intelligence/analyzer.ts | 202 ++++++++++
packages/core/src/app-intelligence/lookup.ts | 53 +++
packages/core/src/app-intelligence/service.ts | 92 +++++
packages/core/src/app-intelligence/storage.ts | 44 +++
packages/core/src/app-intelligence/tools.ts | 83 ++++
packages/core/src/app-intelligence/worker.ts | 27 ++
packages/core/src/panel-tools.ts | 22 +-
packages/core/src/plugin.ts | 8 +-
packages/core/src/profile-picker.ts | 6 +-
packages/core/src/telemetry.ts | 9 +-
packages/core/test/app-intelligence.test.ts | 85 ++++
packages/core/test/browser-panel.test.ts | 4 +-
packages/core/test/plugin.test.ts | 11 +-
packages/core/test/telemetry.test.ts | 13 +-
packages/mcp-app/src/browser-panel.ts | 4 +-
packages/mcp-app/src/components/workspace.tsx | 15 +-
packages/mcp-app/src/workspace-app.tsx | 6 +-
packages/shared/src/app-intelligence.ts | 51 +++
packages/shared/src/panel-target.ts | 3 +-
packages/shared/src/telemetry.ts | 2 +-
packages/skills/mobile-dev/SKILL.md | 4 +
packages/ui/src/app-intelligence-layout.ts | 345 +++++++++++++++++
packages/ui/src/app-intelligence-panel.ts | 100 +++++
.../src/components/app-intelligence-view.tsx | 362 ++++++++++++++++++
packages/ui/src/workspace-tools.ts | 10 +-
.../ui/test/app-intelligence-layout.test.ts | 192 ++++++++++
.../ui/test/app-intelligence-view.test.ts | 142 +++++++
packages/ui/test/react-ui.test.ts | 4 +-
packages/ui/test/ui-telemetry.test.ts | 42 ++
test-codex-local.sh | 115 ++++++
tooling/host-runtime.mjs | 2 +-
tooling/hosts/hosts.spec.ts | 2 +-
.../codex/workspace-app-intel-dark.png | Bin 0 -> 29545 bytes
.../codex/workspace-app-intel-light.png | Bin 0 -> 28834 bytes
tooling/hosts/visual.spec.ts | 2 +-
tooling/server-bundle.mjs | 5 +-
43 files changed, 2243 insertions(+), 65 deletions(-)
create mode 100644 packages/core/src/app-intelligence/analyzer.ts
create mode 100644 packages/core/src/app-intelligence/lookup.ts
create mode 100644 packages/core/src/app-intelligence/service.ts
create mode 100644 packages/core/src/app-intelligence/storage.ts
create mode 100644 packages/core/src/app-intelligence/tools.ts
create mode 100644 packages/core/src/app-intelligence/worker.ts
create mode 100644 packages/core/test/app-intelligence.test.ts
create mode 100644 packages/shared/src/app-intelligence.ts
create mode 100644 packages/ui/src/app-intelligence-layout.ts
create mode 100644 packages/ui/src/app-intelligence-panel.ts
create mode 100644 packages/ui/src/components/app-intelligence-view.tsx
create mode 100644 packages/ui/test/app-intelligence-layout.test.ts
create mode 100644 packages/ui/test/app-intelligence-view.test.ts
create mode 100755 test-codex-local.sh
create mode 100644 tooling/hosts/screenshots/codex/workspace-app-intel-dark.png
create mode 100644 tooling/hosts/screenshots/codex/workspace-app-intel-light.png
diff --git a/apps/claude/skills/mobile-dev/SKILL.md b/apps/claude/skills/mobile-dev/SKILL.md
index f55b8100..9d1ef6d0 100644
--- a/apps/claude/skills/mobile-dev/SKILL.md
+++ b/apps/claude/skills/mobile-dev/SKILL.md
@@ -38,6 +38,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/tooling/smoke-package.mjs b/apps/codex-plugin/tooling/smoke-package.mjs
index 7ef976c1..abc2882a 100644
--- a/apps/codex-plugin/tooling/smoke-package.mjs
+++ b/apps/codex-plugin/tooling/smoke-package.mjs
@@ -172,7 +172,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..660a0037 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 Intelligence"
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 251e0afd..0c1619b7 100644
--- a/docs/telemetry.md
+++ b/docs/telemetry.md
@@ -595,7 +595,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=simulator`, `logs`, `performance`, `recording` or `comparison`, plus view, visible device layout and monitoring state. 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=simulator`, `logs`, `performance`, `storage`, `app-intel`, `tracesift`, `recording` or `comparison`, plus view, visible device layout and monitoring state. 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. |
@@ -654,3 +654,18 @@ logs, arbitrary names and credentials remain excluded. Collection uses the
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 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.atlas_layout` and `ui.app_intel.flow_layout`
+time the two view layouts — the directory treemap and the per-screen dependency
+layering — and `ui.app_intel.atlas_cells` and `ui.app_intel.flow_nodes` gauge how
+many nodes each one drew, which is what drives that cost. `ui.app_intel.view_switch`
+and `ui.app_intel.zoom` count navigation between the two views and into a
+directory, and `ui.app_intel.boundary_filter` counts how often the Flow's
+boundary filter is changed. The filter counts the change only; which boundaries
+were 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.
diff --git a/package-lock.json b/package-lock.json
index 9227bc73..7e3e39dd 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",
@@ -32,6 +33,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",
@@ -73,11 +75,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"
@@ -197,6 +194,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",
@@ -214,6 +227,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",
@@ -378,9 +407,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": {
@@ -422,19 +451,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": {
@@ -582,6 +641,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",
@@ -601,14 +676,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": {
@@ -2100,6 +2191,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4052,6 +4144,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4065,6 +4158,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4078,6 +4172,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4091,6 +4186,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4104,6 +4200,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4117,6 +4214,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4130,6 +4228,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4143,6 +4242,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4156,6 +4256,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4169,6 +4270,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4182,6 +4284,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4195,6 +4298,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4208,6 +4312,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4221,6 +4326,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4234,6 +4340,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4247,6 +4354,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4260,6 +4368,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4273,6 +4382,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4286,6 +4396,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4299,6 +4410,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4312,6 +4424,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4325,6 +4438,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4338,6 +4452,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4351,6 +4466,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4364,6 +4480,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5251,6 +5368,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",
@@ -5272,6 +5405,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",
@@ -6552,10 +6701,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",
@@ -7018,6 +7166,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -7215,7 +7364,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": {
@@ -9698,6 +9846,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",
@@ -10083,7 +10247,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 f3de4dd5..8ddab8ce 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,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",
@@ -84,6 +85,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..0851f3fc
--- /dev/null
+++ b/packages/core/src/app-intelligence/analyzer.ts
@@ -0,0 +1,202 @@
+import { createHash, randomUUID } from "node:crypto";
+import { readFile, readdir, realpath } from "node:fs/promises";
+import { dirname, extname, 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 ParsedSource = { path: string; code: string; imports: Array<{ specifier: string; typeOnly: boolean; dynamic: boolean; local?: string }>; navigation: string[]; registrations: Array<{ name: string; local?: string }> };
+
+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);
+ }
+}
+
+function parseSource(path: string, code: string, diagnostics: IntelDiagnostic[]): ParsedSource {
+ const found: ParsedSource = { path, code, imports: [], navigation: [], registrations: [] };
+ 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") {
+ const callee = node.callee;
+ if (callee?.type === "Import") found.imports.push({ specifier: node.arguments[0].value, typeOnly: false, dynamic: true });
+ const method = callee?.property?.name ?? callee?.name;
+ if (["navigate", "push", "replace"].includes(method)) found.navigation.push(node.arguments[0].value);
+ } 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") as any)?.expression?.name;
+ if (typeof screenName === "string") found.registrations.push({ name: screenName, local: component });
+ }
+ });
+ } catch (error) {
+ diagnostics.push({ code: "parse-error", file: path, message: error instanceof Error ? error.message.split("\n")[0] : String(error) });
+ }
+ return found;
+}
+
+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: Array<{ prefix: string; suffix: string; targets: string[] }> = [];
+ try {
+ const tsconfig = JSON.parse((await readFile(join(root, "tsconfig.json"), "utf8")).replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""));
+ const base = resolve(root, tsconfig.compilerOptions?.baseUrl ?? ".");
+ for (const [pattern, targets] of Object.entries(tsconfig.compilerOptions?.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)) });
+ }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") diagnostics.push({ code: "tsconfig", file: "tsconfig.json", message: "Could not read tsconfig path aliases; normal module resolution was used." });
+ }
+ 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.replace("*", 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 edges: IntelEdge[] = [];
+ const packages: AppIntelSnapshot["packages"] = Object.create(null);
+ const importTargets = new Map>();
+ for (const source of parsed.values()) {
+ const locals = 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)) try {
+ const result = resolveFor.get(platform)!(resolve(root, dirname(source.path)), request);
+ if (!result || !inside(root, result)) continue;
+ const target = portable(root, result);
+ const key = fileRef(target);
+ resolved.set(key, [...(resolved.get(key) ?? []), platform]);
+ if (item.local) locals.set(item.local, target);
+ break;
+ } catch { /* try the next alias or represent it with a diagnostic below */ }
+ }
+ if (resolved.size === 0 && !item.specifier.startsWith(".") && !item.specifier.startsWith("/")) {
+ const name = packageName(item.specifier); packages[name] ??= { name, reach: 0 };
+ 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 });
+ }
+ importTargets.set(source.path, locals);
+ }
+ 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.local ? importTargets.get(path)?.get(registration.local) ?? path : path;
+ screens[id] = { title: registration.name, route: null, source: target, 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++; }
+ else if (edge.to.startsWith("package:")) { const pkg = packages[edge.to.slice(8)]; if (pkg) pkg.reach++; }
+ 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..b74c7556
--- /dev/null
+++ b/packages/core/src/app-intelligence/service.ts
@@ -0,0 +1,92 @@
+import { randomUUID } from "node:crypto";
+import { Worker } from "node:worker_threads";
+import type { AppIntelSnapshot, AppIntelStatus } from "@mobile-dev/shared/app-intelligence.ts";
+import { readAppIntel, 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): Promise<{ projectPath: string; snapshot: AppIntelSnapshot | null }> {
+ const projectPath = await this.project(path); return { projectPath, snapshot: await readAppIntel(projectPath) };
+ }
+
+ 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 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..829502f3
--- /dev/null
+++ b/packages/core/src/app-intelligence/storage.ts
@@ -0,0 +1,44 @@
+import { createHash } from "node:crypto";
+import { mkdir, readFile, realpath, rename, 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);
+}
+
+export async function readRememberedProject(): Promise {
+ try { const value = JSON.parse(await readFile(preferencePath, "utf8")); return typeof value.projectPath === "string" ? value.projectPath : null; }
+ catch { return null; }
+}
+
+export async function rememberProject(projectPath: string) { await atomicWrite(preferencePath, JSON.stringify({ projectPath })); }
+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));
+}
+
diff --git a/packages/core/src/app-intelligence/tools.ts b/packages/core/src/app-intelligence/tools.ts
new file mode 100644
index 00000000..416e76c8
--- /dev/null
+++ b/packages/core/src/app-intelligence/tools.ts
@@ -0,0 +1,83 @@
+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 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.",
+ 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.",
+ 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_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); return 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_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 86fa25a5..bf458912 100644
--- a/packages/core/src/panel-tools.ts
+++ b/packages/core/src/panel-tools.ts
@@ -68,7 +68,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_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. 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 = {
@@ -210,9 +210,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));
@@ -221,20 +222,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, {
@@ -243,7 +244,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", {
@@ -256,6 +257,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. 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 8e7d1112..baaf34d5 100644
--- a/packages/core/src/plugin.ts
+++ b/packages/core/src/plugin.ts
@@ -36,9 +36,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 {
@@ -70,7 +72,7 @@ export async function createPlugin(html: string | (() => Promise), o
const fps = new DisplayFpsSessions();
const server = new McpServer({ name: "mobile-dev", version: PLUGIN_VERSION }, { instructions: panel?.instructions ?? INSTRUCTIONS });
instrumentMcpServer(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 = () => {
setServerTelemetryHost(server.server.getClientVersion()?.name);
@@ -95,6 +97,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 resource = async () => typeof html === "string" ? { html } : await html();
const page = async () => configureUI((await resource()).html);
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 c50602ea..b20145d0 100644
--- a/packages/core/src/telemetry.ts
+++ b/packages/core/src/telemetry.ts
@@ -53,6 +53,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", 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 });
+}
+
export function recordTraceSiftOperation(operation: "ready" | "analyze" | "remember" | "restore" | "handoff" | "task" | "payload" | "pick" | "project", outcome: "success" | "failed", duration: number) {
if (process.env.MOBILE_DEV_TELEMETRY === "off") return;
const attributes = { component: "server", surface: "tracesift", operation, outcome };
@@ -282,7 +289,7 @@ const resourceKinds: Record = {
"android-stream:": "android_video", "ios-video:": "ios_video", "ui:": "ui",
};
const allowedContext: Record = {
- surface: ["logs", "performance", "storage", "simulator", "recording", "comparison", "tracesift"], view: ["panel", "workspace", "recording", "comparison", "tracesift"], layout: ["ios", "android", "both", "none"],
+ surface: ["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..94c3527a
--- /dev/null
+++ b/packages/core/test/app-intelligence.test.ts
@@ -0,0 +1,85 @@
+import assert from "node:assert/strict";
+import { mkdtemp, mkdir, rm, 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 { 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 }); }
+});
diff --git a/packages/core/test/browser-panel.test.ts b/packages/core/test/browser-panel.test.ts
index 25a705ea..a08c2eb4 100644
--- a/packages/core/test/browser-panel.test.ts
+++ b/packages/core/test/browser-panel.test.ts
@@ -142,7 +142,7 @@ 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"]) {
+ for (const name of ["mobile_open_simulator", "mobile_open_workspace", "mobile_open_app_intel"]) {
const result = await host.callTool({ name, arguments: { platform: "android" } });
const texts = (result.content as Array<{ text: string }>).map(item => item.text);
const structured = result.structuredContent as { browserPanel?: { url: string } };
@@ -175,7 +175,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 529d40b6..cd2e48eb 100644
--- a/packages/core/test/plugin.test.ts
+++ b/packages/core/test/plugin.test.ts
@@ -19,7 +19,7 @@ import { createTestPlugin, fakeBaguette, fakeSimulatorInput, UDID, PNG } from ".
import { getTelemetryIdentity } from "../src/telemetry-identity.ts";
import manifest from "../../../package.json" with { type: "json" };
-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();
@@ -36,6 +36,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);
});
@@ -231,7 +235,10 @@ 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"]);
+ 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 9b8452c0..6c9857ab 100644
--- a/packages/core/test/telemetry.test.ts
+++ b/packages/core/test/telemetry.test.ts
@@ -470,6 +470,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" },
@@ -487,14 +494,14 @@ 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, TraceSift and two host checks.
- assert.equal(errors.length, 6);
+ // One handled failure per tool call above: logs, recording, comparison, TraceSift, App Intelligence and two host checks.
+ assert.equal(errors.length, 7);
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, 6);
+ assert.equal(afterErrors.length, 7);
});
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..0ab3be7c 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"]) {
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 2baa5da2..979afe2e 100644
--- a/packages/mcp-app/src/components/workspace.tsx
+++ b/packages/mcp-app/src/components/workspace.tsx
@@ -1,12 +1,13 @@
-import { ActivityIcon, DatabaseIcon, TerminalIcon, PanelsTopLeftIcon, ChartNoAxesCombinedIcon } from "lucide-react";
+import { ActivityIcon, DatabaseIcon, 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";
@@ -25,13 +26,15 @@ 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 Intelligence", icon: NetworkIcon },
tracesift: { id: "tool-tracesift", label: "TraceSift", icon: ChartNoAxesCombinedIcon },
};
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 [tool, setTool] = useState("logs");
const [savedVisible, setSavedVisible] = useState(false);
const [simulatorsCollapsed, setSimulatorsCollapsed] = useState(false);
@@ -60,8 +63,9 @@ 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);
// 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 }[tool];
+ const open = { logs: logState.open, performance: savedVisible || performanceState.open, storage: storageState.open, "app-intel": appIntelState.open, tracesift: traceSiftState.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 });
@@ -77,6 +81,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();
@@ -90,7 +95,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 === "performance" && open && (savedVisible ?
: )};
+ }}>{tool === "logs" && }{tool === "storage" && }{tool === "app-intel" && }{tool === "tracesift" && }{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 e950cf84..10d69cad 100644
--- a/packages/mcp-app/src/workspace-app.tsx
+++ b/packages/mcp-app/src/workspace-app.tsx
@@ -13,6 +13,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";
@@ -31,9 +32,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);
}
@@ -111,6 +114,7 @@ export function startWorkspaceApp(options: WorkspaceOptions = {}) {
connectionFailed(error);
return;
}
+ if (target.data.workspaceTool) { automaticTool = target.data.workspaceTool; automaticToolRevision++; renderWorkspace(); }
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..4ae494ae
--- /dev/null
+++ b/packages/shared/src/app-intelligence.ts
@@ -0,0 +1,51 @@
+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;
+ 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..bbf2452a 100644
--- a/packages/shared/src/panel-target.ts
+++ b/packages/shared/src/panel-target.ts
@@ -3,8 +3,9 @@ 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.");
-export const panelTargetSchema = z.object({ platform, kind, deviceId }).strict().superRefine((target, context) => {
+export const panelTargetSchema = z.object({ platform, kind, deviceId, workspaceTool }).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 b5c40858..0b00c824 100644
--- a/packages/shared/src/telemetry.ts
+++ b/packages/shared/src/telemetry.ts
@@ -33,7 +33,7 @@ export function validateTelemetryEnvironment(value: unknown): TelemetryEnvironme
throw new Error("Sentry environment must be development or release.");
}
-export type Surface = "logs" | "performance" | "storage" | "simulator" | "recording" | "comparison" | "tracesift";
+export type Surface = "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 bd541312..cc84ddd8 100644
--- a/packages/skills/mobile-dev/SKILL.md
+++ b/packages/skills/mobile-dev/SKILL.md
@@ -27,6 +27,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..1005a12a
--- /dev/null
+++ b/packages/ui/src/app-intelligence-layout.ts
@@ -0,0 +1,345 @@
+import type { AppIntelSnapshot, IntelPlatform, IntelRef } from "@mobile-dev/shared/app-intelligence.ts";
+
+/**
+ * Pure geometry and aggregation for the App Intelligence views. 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 TrieLeaf = { path: string; kind: "source" | "asset" | "package"; reach: number; platforms: NodePlatforms };
+export type Rect = { x: number; y: number; width: number; height: number };
+export type TrieNode = { name: string; path: string; value: number; reach: number; kids: TrieNode[]; leaf: TrieLeaf | null; parent: TrieNode | null };
+export type PlacedNode = { node: TrieNode; rect: Rect };
+
+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 maxReach(snapshot: AppIntelSnapshot): number {
+ let max = 1;
+ for (const file of Object.values(snapshot.files)) max = Math.max(max, file.reach);
+ for (const entry of Object.values(snapshot.packages)) max = Math.max(max, entry.reach);
+ return max;
+}
+
+function emptyNode(name: string, path: string, parent: TrieNode | null): TrieNode {
+ return { name, path, value: 0, reach: 0, kids: [], leaf: null, parent };
+}
+
+function insert(root: TrieNode, path: string, leaf: TrieLeaf) {
+ const parts = path.split("/");
+ let node = root;
+ for (let index = 0; index < parts.length; index++) {
+ const segment = parts[index], full = parts.slice(0, index + 1).join("/");
+ let next = node.kids.find(kid => kid.name === segment);
+ if (!next) { next = emptyNode(segment, full, node); node.kids.push(next); }
+ node = next;
+ }
+ node.leaf = leaf;
+ node.value = leaf.kind === "package" ? Math.max(1, leaf.reach > 0 ? Math.round(Math.log2(leaf.reach + 1)) : 1) : 1;
+ node.reach = leaf.reach;
+}
+
+function roll(node: TrieNode): TrieNode {
+ if (!node.kids.length) return node;
+ let value = 0, weighted = 0;
+ for (const kid of node.kids) { roll(kid); value += kid.value; weighted += kid.reach * kid.value; }
+ node.value = value;
+ node.reach = value ? weighted / value : 0;
+ node.kids.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name));
+ return node;
+}
+
+/**
+ * Directory trie over the snapshot. Area comes from file count, colour from reach rolled up as a
+ * value-weighted mean, so a directory reads as hot only when its bulk is hot.
+ */
+export function buildTrie(snapshot: AppIntelSnapshot, options: { withPackages: boolean }): TrieNode {
+ const root = emptyNode("", "", null);
+ for (const [path, file] of Object.entries(snapshot.files)) {
+ insert(root, path, { path, kind: file.kind, reach: file.reach, platforms: platformsFor(snapshot, `file:${path}`) });
+ }
+ if (options.withPackages) for (const [name, entry] of Object.entries(snapshot.packages)) {
+ insert(root, PACKAGE_PREFIX + name, { path: name, kind: "package", reach: entry.reach, platforms: platformsFor(snapshot, `package:${name}`) });
+ }
+ return roll(root);
+}
+
+export function findNode(root: TrieNode, path: string): TrieNode | null {
+ if (!path) return root;
+ for (const kid of root.kids) {
+ if (kid.path === path) return kid;
+ if (path.startsWith(kid.path + "/")) return findNode(kid, path);
+ }
+ return null;
+}
+
+export function leavesOf(node: TrieNode, into: TrieNode[] = []): TrieNode[] {
+ if (node.leaf) into.push(node);
+ for (const kid of node.kids) leavesOf(kid, into);
+ return into;
+}
+
+export function ancestorsOf(node: TrieNode): TrieNode[] {
+ const chain: TrieNode[] = [];
+ for (let current = node.parent; current && current.path; current = current.parent) chain.unshift(current);
+ return chain;
+}
+
+/**
+ * Squarified treemap (Bruls, Huizing & van Wijk): pack rows along the shorter side, closing a row
+ * as soon as adding the next item would make its worst aspect ratio worse.
+ */
+export function squarify(items: TrieNode[], rect: Rect): PlacedNode[] {
+ const placed: PlacedNode[] = [];
+ const pending = items.filter(item => item.value > 0);
+ let remaining = pending.reduce((sum, item) => sum + item.value, 0);
+ let box = { ...rect };
+ while (pending.length && box.width > 0.5 && box.height > 0.5 && remaining > 0) {
+ const vertical = box.width >= box.height;
+ const side = vertical ? box.height : box.width;
+ const area = box.width * box.height;
+ const row: TrieNode[] = [];
+ let sum = 0, best = Infinity;
+ while (pending.length) {
+ const candidate = pending[0];
+ const next = sum + candidate.value;
+ const thickness = (next * area / remaining) / side;
+ let worst = 0;
+ for (const member of [...row, candidate]) {
+ const length = (member.value * area / remaining) / thickness;
+ worst = Math.max(worst, Math.max(thickness / length, length / thickness));
+ }
+ if (!row.length || worst <= best) { row.push(pending.shift() as TrieNode); sum = next; best = worst; }
+ else break;
+ }
+ const thickness = (sum * area / remaining) / side;
+ let offset = vertical ? box.y : box.x;
+ for (const member of row) {
+ const length = (member.value * area / remaining) / thickness;
+ placed.push({ node: member, rect: vertical ? { x: box.x, y: offset, width: thickness, height: length } : { x: offset, y: box.y, width: length, height: thickness } });
+ offset += length;
+ }
+ if (vertical) { box = { ...box, x: box.x + thickness, width: box.width - thickness }; }
+ else { box = { ...box, y: box.y + thickness, height: box.height - thickness }; }
+ remaining -= sum;
+ }
+ return placed;
+}
+
+export type FlowGraph = { start: IntelRef; levels: IntelRef[][]; hidden: number[]; parent: Map };
+
+export const FLOW_CAPS = [1, 12, 18, 22];
+
+/**
+ * Breadth-first layering of one screen's dependencies, one level per import hop. Levels are capped
+ * and the overflow counted rather than dropped, so a crowded depth still reports its real size.
+ */
+export function buildFlow(snapshot: AppIntelSnapshot, start: IntelRef, caps: number[] = FLOW_CAPS, allow?: ReadonlySet): FlowGraph {
+ const levels: IntelRef[][] = [[start]];
+ const hidden = [0, 0, 0, 0];
+ const parent = new Map();
+ const visited = new Set([start]);
+ for (let depth = 0; depth < caps.length - 1; depth++) {
+ const next: IntelRef[] = [];
+ for (const ref of levels[depth]) for (const index of snapshot.indexes.outgoing[ref] ?? []) {
+ const edge = snapshot.edges[index];
+ if (!edge.to || edge.kind === "navigation" || visited.has(edge.to)) continue;
+ // A filtered-out boundary prunes its subtree: its imports belong to it, not to the screen.
+ if (allow && !allow.has(boundaryOf(edge.to))) continue;
+ visited.add(edge.to);
+ parent.set(edge.to, ref);
+ next.push(edge.to);
+ }
+ next.sort((a, b) => reachOf(snapshot, b) - reachOf(snapshot, a));
+ const cap = caps[depth + 1];
+ for (const ref of next.slice(cap)) parent.delete(ref);
+ levels[depth + 1] = next.slice(0, cap);
+ hidden[depth + 1] = Math.max(0, next.length - cap);
+ levels[depth + 1].sort((a, b) => groupOf(a) === groupOf(b) ? reachOf(snapshot, b) - reachOf(snapshot, a) : groupOf(a).localeCompare(groupOf(b)));
+ }
+ return { start, levels, hidden, parent };
+}
+
+/** Walks incoming edges back to a screen that reaches this node, for "show me where this is used". */
+export function ownerScreen(snapshot: AppIntelSnapshot, ref: IntelRef): string | null {
+ const queue: IntelRef[] = [ref];
+ const visited = new Set(queue);
+ for (let cursor = 0; cursor < queue.length; cursor++) for (const index of snapshot.indexes.incoming[queue[cursor]] ?? []) {
+ const from = snapshot.edges[index].from;
+ if (from.startsWith("screen:")) return from.slice(7);
+ if (visited.has(from)) continue;
+ visited.add(from);
+ queue.push(from);
+ }
+ return null;
+}
+
+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;
+}
+
+/** Top two path segments, which is what clusters a column by the area of the app it belongs to. */
+export function groupOf(ref: IntelRef): string {
+ const path = ref.replace(/^(?:file|package|screen):/, "");
+ return ref.startsWith("package:") ? PACKAGE_PREFIX : path.split("/").slice(0, 2).join("/");
+}
+
+export function labelOf(ref: IntelRef): string {
+ const path = ref.replace(/^(?:file|package|screen):/, "");
+ return ref.startsWith("package:") ? path : path.split("/").pop() ?? path;
+}
+
+const RAMP = ["#1d4a57", "#1f7d82", "#2fa37f", "#79b85a", "#d7c045", "#e09440", "#dc5440"] as const;
+
+/**
+ * Log-scaled, because most files are reached by a handful of screens: a linear ramp renders the
+ * whole map cold and hides the difference between 2 screens and 20.
+ */
+export function heatT(reach: number, max: number): number {
+ if (!(max > 0) || reach <= 0) return 0;
+ return Math.min(1, Math.log1p(reach) / Math.log1p(max));
+}
+
+export function heat(reach: number, max: number): string {
+ const position = heatT(reach, max) * (RAMP.length - 1);
+ const index = Math.min(RAMP.length - 2, Math.floor(position));
+ const fraction = Math.max(0, Math.min(1, position - index));
+ const from = channels(RAMP[index]), to = channels(RAMP[index + 1]);
+ return `rgb(${from.map((value, slot) => Math.round(value + (to[slot] - value) * fraction)).join(",")})`;
+}
+
+/** Text that stays legible on a heat cell in either theme, since the ramp ignores the theme. */
+export function heatInk(reach: number, max: number): string {
+ const [red, green, blue] = channels(RAMP[Math.round(heatT(reach, max) * (RAMP.length - 1))]);
+ return red * 0.299 + green * 0.587 + blue * 0.114 > 140 ? "#10151a" : "#e9eef3";
+}
+
+export const heatRamp = () => `linear-gradient(90deg, ${RAMP.join(",")})`;
+
+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. The Flow used one muted card for every hop, which 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 = ["screen", "navigation", "hook", "component", "state", "api", "model", "native", "asset", "util", "package"] as const;
+export type Boundary = typeof boundaries[number];
+
+export const boundaryLabel: Record = {
+ screen: "Screens", navigation: "Navigation", hook: "Hooks", component: "Components", state: "State",
+ api: "API & data", model: "Types & models", native: "Native", asset: "Assets", util: "Utilities", package: "Packages",
+};
+
+const BOUNDARY_COLOR: Record = {
+ screen: "#8b5cf6", navigation: "#6366f1", hook: "#0ea5e9", component: "#14b8a6", state: "#f97316",
+ api: "#ef4444", model: "#a855f7", native: "#eab308", asset: "#84cc16", util: "#64748b", package: "#94a3b8",
+};
+
+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"]],
+ ["screen", ["screen", "page", "view", "scene", "route-group"]],
+ ["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 "screen";
+ if (ref.startsWith("package:")) return "package";
+ const path = ref.slice(5);
+ 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" : "util";
+}
+
+/** The boundaries a graph actually contains, in display order, with how many nodes each holds. */
+export function boundariesIn(refs: Iterable): Array<{ boundary: Boundary; count: number }> {
+ const counts = new Map();
+ for (const ref of refs) counts.set(boundaryOf(ref), (counts.get(boundaryOf(ref)) ?? 0) + 1);
+ return boundaries.filter(boundary => counts.has(boundary)).map(boundary => ({ boundary, count: counts.get(boundary) as number }));
+}
+
+/**
+ * Every boundary reachable from a start within the Flow's hop budget, ignoring the per-level caps,
+ * so the filter offers the app's real boundaries instead of only those that fit on screen.
+ */
+export function flowBoundaries(snapshot: AppIntelSnapshot, start: IntelRef, hops = FLOW_CAPS.length - 1): Array<{ boundary: Boundary; count: number }> {
+ const seen = new Set([start]);
+ let frontier: IntelRef[] = [start];
+ const found: IntelRef[] = [];
+ for (let depth = 0; depth < hops; 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" || seen.has(edge.to)) continue;
+ seen.add(edge.to);
+ next.push(edge.to);
+ found.push(edge.to);
+ }
+ frontier = next;
+ }
+ return boundariesIn(found);
+}
diff --git a/packages/ui/src/app-intelligence-panel.ts b/packages/ui/src/app-intelligence-panel.ts
new file mode 100644
index 00000000..c16a3557
--- /dev/null
+++ b/packages/ui/src/app-intelligence-panel.ts
@@ -0,0 +1,100 @@
+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" | null;
+ error: string; selectedScreen: string | null;
+ view: "atlas" | "flow"; atlasPath: string; showPackages: boolean; flowRoot: 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, view: "atlas", atlasPath: "", showPackages: false, flowRoot: null, boundaries: null };
+ 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(); }
+ setAvailable(available: boolean) { this.update({ available }); if (available && this.state.open) void this.load(); }
+ show() { this.update({ open: true }); if (this.state.available) void this.load(); }
+ hide() { this.update({ open: false }); }
+ dispose() { this.listeners.clear(); }
+ selectScreen(selectedScreen: string | null) { this.update({ selectedScreen, flowRoot: null, view: "flow", boundaries: null }); }
+ setView(view: AppIntelligenceState["view"]) { if (view !== this.state.view) { countUiEvent("ui.app_intel.view_switch"); this.update({ view }); } }
+ zoomTo(atlasPath: string) { if (atlasPath !== this.state.atlasPath) { countUiEvent("ui.app_intel.zoom"); this.update({ atlasPath }); } }
+ setPackages(showPackages: boolean) { this.update({ showPackages }); }
+ setFlowRoot(flowRoot: string | null) { this.update({ flowRoot }); }
+ 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) {
+ if (!this.state.available || this.state.busy === "generating") return;
+ this.update({ 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 (result.isError) throw new Error(resultError(result));
+ const value = result.structuredContent as { projectPath: string; snapshot: AppIntelSnapshot | null };
+ 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;
+ const flowRoot = value.snapshot && this.state.flowRoot && value.snapshot.files[this.state.flowRoot] ? this.state.flowRoot : null;
+ const atlasPath = value.snapshot && this.state.atlasPath && Object.keys(value.snapshot.files).some(path => path === this.state.atlasPath || path.startsWith(this.state.atlasPath + "/")) ? this.state.atlasPath : "";
+ this.update({ projectPath: value.projectPath, snapshot: value.snapshot, busy: null, selectedScreen: screen, flowRoot, atlasPath });
+ } catch (error) {
+ 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.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, flowRoot: null, atlasPath: "", view: "atlas", 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"); }
+ }
+
+ async generate() {
+ if (!this.state.available || !this.state.projectPath) return;
+ 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);
+ }
+ }
+
+ 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..2c27d50a
--- /dev/null
+++ b/packages/ui/src/components/app-intelligence-view.tsx
@@ -0,0 +1,362 @@
+import { FolderOpenIcon, ListFilterIcon, NetworkIcon, RefreshCwIcon, SearchIcon, SquareIcon } from "lucide-react";
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState, useSyncExternalStore } from "react";
+import type { ReactNode } 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, Rect, TrieNode } from "../app-intelligence-layout.ts";
+import { FLOW_CAPS, boundaryColor, boundaryLabel, boundaryOf, buildFlow, buildTrie, depthHue, depthTint, findNode, flowBoundaries, groupOf, heat, heatInk, heatRamp, heatT, labelOf, leavesOf, ancestorsOf, maxReach, ownerScreen, platformsFor, reachOf, squarify } 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 { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group.tsx";
+
+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;
+}
+
+/**
+ * Drag-to-pan for a scrolling box. A drag that actually moves swallows the click that follows it,
+ * so panning across the Flow never re-roots the graph on whatever card was under the pointer.
+ */
+function useDragPan(element: HTMLElement | null) {
+ useEffect(() => {
+ if (!element) return;
+ let origin: { x: number; y: number; left: number; top: 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, left: element.scrollLeft, top: element.scrollTop };
+ 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";
+ element.scrollLeft = origin.left - dx;
+ element.scrollTop = origin.top - dy;
+ };
+ const up = () => {
+ if (moved) element.addEventListener("click", swallow, { capture: true, once: true });
+ origin = null;
+ moved = false;
+ delete element.dataset.panning;
+ };
+ element.addEventListener("pointerdown", down);
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ window.addEventListener("pointercancel", up);
+ return () => {
+ element.removeEventListener("pointerdown", down);
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ window.removeEventListener("pointercancel", up);
+ };
+ }, [element]);
+}
+
+type Hover = { title: string; detail: string; x: number; y: number } | null;
+
+function AtlasCell({ node, rect, max, dim, onZoom, onTrace, onHover }: {
+ node: TrieNode; rect: Rect; max: number; dim: boolean;
+ onZoom: (path: string) => void; onTrace: (node: TrieNode) => void; onHover: (hover: Hover) => void;
+}) {
+ const leaf = node.leaf;
+ const fill = heat(node.reach, max);
+ const detail = `${leaf ? "" : `${node.value} files · `}reach ${Math.round(node.reach)}${leaf && leaf.platforms !== "both" ? ` · ${platformLabel(leaf.platforms)}` : ""}`;
+ return ;
+}
+
+function atlasCells(node: TrieNode, rect: Rect, depth: number, context: {
+ max: number; matches: (path: string) => boolean; onZoom: (path: string) => void; onTrace: (node: TrieNode) => void; onHover: (hover: Hover) => void;
+}, out: ReactNode[]) {
+ for (const { node: kid, rect: box } of squarify(node.kids, rect)) {
+ if (box.width < 1.5 || box.height < 1.5) continue;
+ const expandable = kid.kids.length > 0 && depth < 2 && box.width > 46 && box.height > 34;
+ if (!expandable) {
+ out.push();
+ continue;
+ }
+ const pad = box.height > 56 && box.width > 70 ? 15 : 3;
+ const groupDetail = `${kid.value} files · reach ${Math.round(kid.reach)}`;
+ out.push();
+ atlasCells(kid, { x: box.x + 2, y: box.y + pad, width: Math.max(1, box.width - 5), height: Math.max(1, box.height - pad - 3) }, depth + 1, context, out);
+ }
+}
+
+const CARD = { width: 184, height: 40, column: 236, padX: 16, padY: 32, gap: 8 };
+
+function FlowView({ snapshot, start, max, height, allow, onRoot, onHover }: {
+ snapshot: AppIntelSnapshot; start: IntelRef; max: number; height: number; allow: ReadonlySet | null;
+ onRoot: (ref: IntelRef) => void; onHover: (hover: Hover) => void;
+}) {
+ const [lit, setLit] = useState(null);
+ const graph = useMemo(() => {
+ const startedAt = performance.now();
+ const built = buildFlow(snapshot, start, FLOW_CAPS, allow ?? undefined);
+ recordUiTiming("ui.app_intel.flow_layout", performance.now() - startedAt);
+ return built;
+ }, [snapshot, start, allow]);
+ const counts = graph.levels.map((level, depth) => level.length + (graph.hidden[depth] ? 1 : 0));
+ const rows = Math.max(1, ...counts);
+ const innerHeight = Math.max(height - CARD.padY - 16, rows * (CARD.height + CARD.gap) - CARD.gap);
+ const board = { width: CARD.padX * 2 + CARD.column * 3 + CARD.width, height: CARD.padY + innerHeight + 16 };
+ useEffect(() => { setUiGauge("ui.app_intel.flow_nodes", graph.levels.reduce((sum, level) => sum + level.length, 0)); }, [graph]);
+
+ const spots = new Map();
+ const overflow: Array<{ x: number; y: number; depth: number; hidden: number }> = [];
+ graph.levels.forEach((level, depth) => {
+ const total = counts[depth] * (CARD.height + CARD.gap) - CARD.gap;
+ const top = CARD.padY + Math.max(0, (innerHeight - total) / 2);
+ const x = CARD.padX + depth * CARD.column;
+ level.forEach((ref, index) => spots.set(ref, { x, y: top + index * (CARD.height + CARD.gap), depth }));
+ if (graph.hidden[depth]) overflow.push({ x, y: top + level.length * (CARD.height + CARD.gap), depth, hidden: graph.hidden[depth] });
+ });
+ const path = new Set();
+ for (let cursor = lit; cursor; cursor = graph.parent.get(cursor) ?? null) path.add(cursor);
+
+ return ;
+}
+
+/** The Flow's boundary filter: one chip per boundary the traced graph actually contains. */
+function BoundaryFilter({ available, selected, onToggle, onAll }: {
+ available: Array<{ boundary: Boundary; count: number }>; selected: ReadonlySet;
+ onToggle: (boundary: Boundary) => void; onAll: () => void;
+}) {
+ if (available.length < 2) return null;
+ const all = available.every(entry => selected.has(entry.boundary));
+ return
+
+
+ {available.map(({ boundary, count }) => {
+ const on = selected.has(boundary);
+ return ;
+ })}
+
;
+}
+
+export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: { panel: AppIntelligencePanel }) {
+ const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot);
+ const [query, setQuery] = useState("");
+ const [hover, setHover] = useState(null);
+ const [atlasRef, atlasSize] = useMeasured();
+ const [flowRef, flowSize, flowBox] = useMeasured();
+ useDragPan(flowBox);
+ const snapshot = state.snapshot;
+ const search = query.trim().toLowerCase();
+
+ const tries = useMemo(() => {
+ if (!snapshot) return null;
+ const startedAt = performance.now();
+ const built = { on: buildTrie(snapshot, { withPackages: true }), off: buildTrie(snapshot, { withPackages: false }) };
+ recordUiTiming("ui.app_intel.atlas_layout", performance.now() - startedAt);
+ return built;
+ }, [snapshot]);
+ const max = useMemo(() => snapshot ? maxReach(snapshot) : 1, [snapshot]);
+ const root = tries ? (state.showPackages ? tries.on : tries.off) : null;
+ const zoomed = root ? findNode(root, state.atlasPath) ?? root : null;
+
+ const cells = useMemo(() => {
+ if (!zoomed || atlasSize.width < 20 || atlasSize.height < 20) return [];
+ const out: ReactNode[] = [];
+ atlasCells(zoomed, { x: 0, y: 0, width: atlasSize.width, height: atlasSize.height }, 0, {
+ max, matches: path => !search || path.toLowerCase().includes(search),
+ onZoom: path => panel.zoomTo(path),
+ onTrace: node => { if (node.leaf?.kind !== "package") traceFile(node.path); },
+ onHover: setHover,
+ }, out);
+ return out;
+ }, [zoomed, atlasSize.width, atlasSize.height, max, search, state.showPackages]);
+ useEffect(() => { setUiGauge("ui.app_intel.atlas_cells", cells.length); }, [cells.length]);
+
+ // A click in the Atlas should land on a screen that actually reaches the file, not an arbitrary one.
+ function traceFile(path: string) {
+ if (!snapshot) return;
+ const direct = Object.entries(snapshot.screens).find(([, screen]) => screen.source === path);
+ if (direct) { panel.selectScreen(direct[0]); return; }
+ const owner = ownerScreen(snapshot, fileRef(path));
+ if (owner) panel.selectScreen(owner);
+ panel.setFlowRoot(path);
+ panel.setView("flow");
+ }
+
+ const selected = state.selectedScreen && snapshot?.screens[state.selectedScreen] ? state.selectedScreen : null;
+ const flowStart: IntelRef | null = state.flowRoot ? fileRef(state.flowRoot) : selected ? screenRef(selected) : null;
+ const available = useMemo(() => snapshot && flowStart ? flowBoundaries(snapshot, flowStart) : [], [snapshot, flowStart]);
+ const selectedBoundaries = useMemo(() => new Set(state.boundaries ?? available.map(entry => entry.boundary)), [state.boundaries, available]);
+ // null means "every boundary", so an unfiltered graph skips the allow-set and its pruning entirely.
+ const allow = useMemo(() => state.boundaries ? selectedBoundaries : null, [state.boundaries, selectedBoundaries]);
+ const toggleBoundary = useCallback((boundary: Boundary) => panel.toggleBoundary(boundary, available.map(entry => entry.boundary)), [panel, available]);
+ const screens = useMemo(() => Object.entries(snapshot?.screens ?? {}).filter(([id, screen]) => !search || `${id} ${screen.title} ${screen.route ?? ""} ${screen.source ?? ""}`.toLowerCase().includes(search)), [snapshot, search]);
+ const hottest = useMemo(() => zoomed ? leavesOf(zoomed).filter(node => !search || node.path.toLowerCase().includes(search)).sort((a, b) => b.reach - a.reach).slice(0, 60) : [], [zoomed, search]);
+
+ return {
+ if (event.key !== "Escape") return;
+ const node = root && state.atlasPath ? findNode(root, state.atlasPath) : null;
+ if (state.view === "atlas" && node) panel.zoomTo(node.parent?.path ?? "");
+ if (state.view === "flow" && state.flowRoot) panel.setFlowRoot(null);
+ }}>
+
+ {state.error && {state.error}}
+ {state.run && {state.run.message}
}
+ {!state.projectPath ? Map a React Native app
Choose a project to discover Expo Router and React Navigation screens, then trace their native dependencies.
+ : !snapshot || !root || !zoomed ? No App Intelligence yet
Generate a static iOS + Android screen and dependency graph.
+ :
+
+ {state.view === "atlas" ? <>
+
+
+
+
reach{max}
+
+ {cells}
+ Area is files, colour is reach — how many entry points pull it in. Click a directory to zoom in, Esc to go back, click a file to trace it.
+ > : <>
+
+ {state.flowRoot ? `Re-rooted on ${state.flowRoot.split("/").pop()}` : snapshot.screens[selected ?? ""]?.title ?? "Choose a screen"}
+ {state.flowRoot && }
+
+ {flowStart && panel.setBoundaries(null)} />}
+
+ {flowStart ?
{ if (ref.startsWith("file:")) panel.setFlowRoot(ref.slice(5)); }} onHover={setHover} />
+ : Choose a screen to trace its dependencies.
}
+
+ One column per import hop, tinted by depth. Drag to pan, hover lights the path back, click re-roots, Esc returns.
+ >}
+
+
+
}
+ {hover &&
+
{hover.title}
+
{hover.detail}
+
}
+ ;
+});
diff --git a/packages/ui/src/workspace-tools.ts b/packages/ui/src/workspace-tools.ts
index 5dd684ef..6419a299 100644
--- a/packages/ui/src/workspace-tools.ts
+++ b/packages/ui/src/workspace-tools.ts
@@ -6,12 +6,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"] as const;
+export const toolNames = ["logs", "performance", "storage", "app-intel", "tracesift"] 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 };
+type Panels = { context: PanelContext; deviceApps: DeviceAppsStore; logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; "app-intel": AppIntelligencePanel; tracesift: TraceSiftPanel };
/** The tool panels every host shows beside its devices; hosts only choose the selected tool and draw chrome. */
export class WorkspaceTools {
@@ -20,6 +21,7 @@ export class WorkspaceTools {
readonly logs: LogsPanel;
readonly performance: PerformancePanel;
readonly storage: StoragePanel;
+ readonly "app-intel": AppIntelligencePanel;
readonly tracesift: TraceSiftPanel;
/** Builds the panels against one host; `attachments` reads a host's composer attachments, if it has them. */
@@ -31,12 +33,13 @@ 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),
});
}
constructor(panels: Panels) {
- ({ context: this.context, deviceApps: this.deviceApps, logs: this.logs, performance: this.performance, storage: this.storage, tracesift: this.tracesift } = panels);
+ ({ context: this.context, deviceApps: this.deviceApps, logs: this.logs, performance: this.performance, storage: this.storage, "app-intel": this["app-intel"], tracesift: this.tracesift } = panels);
}
panel(name: ToolName) { return this[name]; }
@@ -79,6 +82,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()]);
}
}
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..bc7fe945
--- /dev/null
+++ b/packages/ui/test/app-intelligence-layout.test.ts
@@ -0,0 +1,192 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { AppIntelSnapshot, IntelEdge } from "@mobile-dev/shared/app-intelligence.ts";
+import { boundaryOf, buildFlow, buildTrie, depthHue, depthTint, findNode, flowBoundaries, heat, heatT, leavesOf, maxReach, ownerScreen, platformsFor, squarify } 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 }])),
+ 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("squarify tiles its rectangle without gaps or overlap", () => {
+ const items = [40, 25, 15, 12, 8].map((value, index) => ({ name: `n${index}`, path: `n${index}`, value, reach: value, kids: [], leaf: null, parent: null }));
+ const rect = { x: 0, y: 0, width: 600, height: 400 };
+ const placed = squarify(items, rect);
+ assert.equal(placed.length, items.length);
+ const area = placed.reduce((sum, item) => sum + item.rect.width * item.rect.height, 0);
+ assert.ok(Math.abs(area - rect.width * rect.height) < 1, `tiled area ${area} should fill ${rect.width * rect.height}`);
+ for (const { rect: box } of placed) {
+ assert.ok(box.x >= -0.001 && box.y >= -0.001 && box.x + box.width <= rect.width + 0.001 && box.y + box.height <= rect.height + 0.001, "stays inside the rect");
+ }
+ for (let a = 0; a < placed.length; a++) for (let b = a + 1; b < placed.length; b++) {
+ const first = placed[a].rect, second = placed[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[a].node.name} overlaps ${placed[b].node.name}`);
+ }
+});
+
+test("squarify ignores zero-valued items and an empty list", () => {
+ assert.deepEqual(squarify([], { x: 0, y: 0, width: 100, height: 100 }), []);
+ const zero = [{ name: "z", path: "z", value: 0, reach: 0, kids: [], leaf: null, parent: null }];
+ assert.deepEqual(squarify(zero, { x: 0, y: 0, width: 100, height: 100 }), []);
+});
+
+test("the trie rolls counts up and hides packages on request", () => {
+ const snapshot = snapshotOf(
+ [importEdge("file:src/a/one.ts", "package:left-pad")],
+ { "src/a/one.ts": 4, "src/a/two.ts": 2, "src/b/three.ts": 1 },
+ { "left-pad": 4 },
+ );
+ const withPackages = buildTrie(snapshot, { withPackages: true });
+ assert.deepEqual(withPackages.kids.map(kid => kid.name).sort(), ["node_modules", "src"]);
+ const appOnly = buildTrie(snapshot, { withPackages: false });
+ assert.deepEqual(appOnly.kids.map(kid => kid.name), ["src"]);
+ assert.equal(appOnly.value, 3, "three files in the app tree");
+
+ const a = findNode(appOnly, "src/a");
+ assert.ok(a);
+ assert.equal(a.value, 2);
+ assert.equal(a.reach, 3, "value-weighted mean of 4 and 2");
+ assert.equal(leavesOf(appOnly).length, 3);
+ assert.equal(findNode(appOnly, "src/nope"), null);
+ assert.equal(findNode(appOnly, "")?.path, "", "an empty path is the root");
+});
+
+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");
+});
+
+test("buildFlow layers by hop, caps each level and counts the overflow", () => {
+ const edges = [
+ { from: "screen:home", to: "file:src/screens/home.tsx", kind: "source", platforms: ["ios", "android"] } as IntelEdge,
+ { from: "screen:home", to: "screen:other", kind: "navigation", platforms: ["ios", "android"] } as IntelEdge,
+ ];
+ const files: Record = { "src/screens/home.tsx": 1 };
+ for (let index = 0; index < 20; index++) {
+ const path = `src/lib/dep${index}.ts`;
+ files[path] = 20 - index;
+ edges.push(importEdge("file:src/screens/home.tsx", `file:${path}`));
+ }
+ const snapshot = snapshotOf(edges, files);
+ const flow = buildFlow(snapshot, "screen:home", [1, 1, 12, 5]);
+ assert.deepEqual(flow.levels[0], ["screen:home"]);
+ assert.deepEqual(flow.levels[1], ["file:src/screens/home.tsx"]);
+ assert.equal(flow.levels[2].length, 12, "depth 2 is capped");
+ assert.equal(flow.hidden[2], 8, "and the rest are counted, not dropped");
+ assert.ok(!flow.levels.flat().includes("screen:other"), "navigation edges never enter the flow");
+ assert.equal(flow.parent.get("file:src/screens/home.tsx"), "screen:home");
+ for (const ref of flow.levels[2]) assert.equal(flow.parent.get(ref), "file:src/screens/home.tsx");
+ assert.ok(flow.levels[2].every(ref => flow.parent.has(ref)), "capped-out nodes leave no dangling parent");
+});
+
+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(flow.levels[1], ["file:b.ts"]);
+ assert.deepEqual(flow.levels[2], [], "a.ts is already visited");
+});
+
+test("the heat scale is log-shaped, monotonic and clamped", () => {
+ assert.equal(heatT(0, 100), 0);
+ assert.equal(heatT(100, 100), 1);
+ assert.equal(heatT(500, 100), 1, "clamps above the max");
+ assert.equal(heatT(5, 0), 0, "an empty snapshot is cold, not NaN");
+ let previous = -1;
+ for (const reach of [0, 1, 2, 5, 10, 50, 100]) {
+ const value = heatT(reach, 100);
+ assert.ok(value > previous || (reach === 0 && value === 0), `heat rises at ${reach}`);
+ previous = value;
+ }
+ assert.ok(heatT(10, 100) > 0.5, "log scale lifts small counts clear of the floor");
+ assert.match(heat(50, 100), /^rgb\(\d+,\d+,\d+\)$/);
+ assert.match(heat(0, 100), /^rgb\(\d+,\d+,\d+\)$/);
+ assert.match(heat(100, 100), /^rgb\(\d+,\d+,\d+\)$/);
+});
+
+test("maxReach spans files and packages", () => {
+ const snapshot = snapshotOf([], { "a.ts": 3 }, { "left-pad": 11 });
+ assert.equal(maxReach(snapshot), 11);
+ assert.equal(maxReach(snapshotOf([], {})), 1, "never zero, so the scale cannot divide by it");
+});
+
+test("ownerScreen walks back to a screen that reaches the file", () => {
+ const snapshot = snapshotOf([
+ { from: "screen:home", to: "file:app/index.tsx", kind: "source", platforms: ["ios", "android"] } as IntelEdge,
+ importEdge("file:app/index.tsx", "file:src/shared.ts"),
+ importEdge("file:src/shared.ts", "package:left-pad"),
+ ], { "app/index.tsx": 1, "src/shared.ts": 1, "orphan.ts": 0 }, { "left-pad": 1 });
+ assert.equal(ownerScreen(snapshot, "file:src/shared.ts"), "home", "two hops back to the screen");
+ assert.equal(ownerScreen(snapshot, "package:left-pad"), "home", "packages resolve too");
+ assert.equal(ownerScreen(snapshot, "file:orphan.ts"), null, "nothing reaches an orphan");
+});
+
+test("boundaryOf reads a role off the path", () => {
+ assert.equal(boundaryOf("screen:home"), "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"), "screen");
+ 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");
+});
+
+test("the Flow filter prunes a boundary and everything only it reached", () => {
+ const snapshot = snapshotOf([
+ importEdge("screen:home", "file:src/screens/home.tsx"),
+ importEdge("file:src/screens/home.tsx", "file:src/hooks/usePosts.ts"),
+ importEdge("file:src/screens/home.tsx", "file:src/components/Avatar.tsx"),
+ importEdge("file:src/hooks/usePosts.ts", "file:src/api/feed.ts"),
+ ], { "src/screens/home.tsx": 1, "src/hooks/usePosts.ts": 1, "src/components/Avatar.tsx": 1, "src/api/feed.ts": 1 });
+
+ assert.deepEqual(flowBoundaries(snapshot, "screen:home").map(entry => entry.boundary).sort(), ["api", "component", "hook", "screen"]);
+ const all = buildFlow(snapshot, "screen:home").levels.flat();
+ assert.ok(all.includes("file:src/api/feed.ts"), "the hook's own import is reached without a filter");
+
+ const filtered = buildFlow(snapshot, "screen:home", undefined, new Set(["screen", "component"])).levels.flat();
+ assert.ok(!filtered.includes("file:src/hooks/usePosts.ts"), "the dropped boundary is gone");
+ assert.ok(!filtered.includes("file:src/api/feed.ts"), "and so is the subtree only it reached");
+ assert.ok(filtered.includes("file:src/components/Avatar.tsx"), "a kept boundary still draws");
+ assert.equal(filtered[0], "screen:home", "the root always survives its own filter");
+});
+
+test("flowBoundaries counts only what the graph contains", () => {
+ const snapshot = snapshotOf([
+ importEdge("screen:home", "file:src/hooks/useA.ts"),
+ importEdge("screen:home", "file:src/hooks/useB.ts"),
+ importEdge("screen:home", "file:src/lib/strings.ts"),
+ ], { "src/hooks/useA.ts": 1, "src/hooks/useB.ts": 1, "src/lib/strings.ts": 1 });
+ assert.deepEqual(flowBoundaries(snapshot, "screen:home"), [{ boundary: "hook", count: 2 }, { boundary: "util", count: 1 }]);
+});
+
+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\)$/);
+});
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..f62d79c5
--- /dev/null
+++ b/packages/ui/test/app-intelligence-view.test.ts
@@ -0,0 +1,142 @@
+import test 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: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) },
+ packages: { "left-pad": { name: "left-pad", reach: 2 } },
+ screens: {
+ home: { title: "Home", route: "/", source: "app/index.tsx", layouts: [] },
+ profile: { title: "Profile", route: "/profile", source: "app/profile.tsx", layouts: [] },
+ },
+ edges, diagnostics: [], indexes,
+ };
+}
+
+test("App Intelligence renders the Atlas, hides packages by default and traces a screen in the Flow", async t => {
+ 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,
+ 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 snapshot = fixture();
+ 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 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 })); });
+ 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);
+ assert.ok(dom.window.document.getElementById("app-intelligence-panel"), "the panel keeps the id the package smoke test asserts");
+ const cells = [...query("[data-atlas-cell]")].map(cell => cell.getAttribute("data-atlas-cell"));
+ assert.ok(cells.length > 0, "the Atlas draws cells");
+ assert.ok(cells.includes("src/shared.ts"), "every file gets a cell");
+ assert.ok(!cells.some(path => path?.startsWith("node_modules")), "packages are hidden until asked for");
+
+ const packages = dom.window.document.getElementById("app-intel-packages") as HTMLButtonElement;
+ assert.equal(packages.getAttribute("aria-pressed"), "false");
+ await act(async () => { packages.click(); });
+ assert.equal(packages.getAttribute("aria-pressed"), "true");
+ assert.ok([...query("[data-atlas-cell]")].some(cell => cell.getAttribute("data-atlas-cell") === "node_modules/left-pad"), "the toggle brings packages in");
+
+ // The hottest file is reached by both screens, so it outranks the two screen sources.
+ const rail = dom.window.document.querySelector("aside .overflow-auto") as HTMLElement;
+ assert.match(rail.textContent ?? "", /src\/shared\.ts/);
+
+ await act(async () => { (dom.window.document.querySelector('[data-view="flow"]') as HTMLButtonElement).click(); });
+ assert.equal(query("[data-flow-node]").length, 0, "no screen is selected yet");
+ const screenButton = [...rail.querySelectorAll("button")].find(button => button.textContent?.includes("Profile"));
+ assert.ok(screenButton, "the rail lists screens in the Flow view");
+ await act(async () => { screenButton.click(); });
+ const nodes = [...query("[data-flow-node]")].map(node => node.getAttribute("data-flow-node"));
+ assert.deepEqual(nodes.slice(0, 2), ["screen:profile", "file:app/profile.tsx"], "the screen leads, then its source");
+ assert.ok(nodes.includes("file:src/shared.ts") && nodes.includes("file:src/only.ios.ts"), "both imports appear at the next hop");
+ assert.ok(!nodes.includes("screen:home"), "the other screen is not part of this flow");
+
+ // The Flow tints each hop rather than drawing one muted ladder.
+ const hops = [...query("[data-flow-node]")].map(node => (node.querySelector("rect") as SVGRectElement).getAttribute("fill"));
+ assert.equal(new Set(hops).size > 1, true, "cards differ in colour by depth");
+
+ // Every boundary the graph holds gets a chip, and clearing one prunes it and its subtree.
+ const chips = () => [...query("[data-flow-filter] [data-boundary]")].map(chip => chip.getAttribute("data-boundary"));
+ assert.deepEqual(chips(), ["all", "util", "package"], "the filter offers only the boundaries this graph reaches");
+ const util = dom.window.document.querySelector('[data-flow-filter] [data-boundary="util"]') as HTMLButtonElement;
+ await act(async () => { util.click(); });
+ assert.equal(util.getAttribute("aria-pressed"), "false");
+ const afterFilter = [...query("[data-flow-node]")].map(node => node.getAttribute("data-flow-node"));
+ assert.deepEqual(afterFilter, ["screen:profile"], "unticking a boundary drops it and the subtree only it reached");
+ await act(async () => { (dom.window.document.querySelector('[data-flow-filter] [data-boundary="all"]') as HTMLButtonElement).click(); });
+ assert.ok([...query("[data-flow-node]")].some(node => node.getAttribute("data-flow-node") === "file:src/shared.ts"), "All brings it back");
+
+ // A directory in the Atlas is clickable in its own right, not only through its children.
+ await act(async () => { (dom.window.document.querySelector('[data-view="atlas"]') as HTMLButtonElement).click(); });
+ const group = dom.window.document.querySelector("[data-atlas-group]") as HTMLButtonElement;
+ assert.ok(group, "the Atlas draws at least one directory frame");
+ const target = group.getAttribute("data-atlas-group") as string;
+ await act(async () => { group.click(); });
+ assert.equal(panel.getSnapshot().atlasPath, target, "clicking the directory name zooms into it");
+});
diff --git a/packages/ui/test/react-ui.test.ts b/packages/ui/test/react-ui.test.ts
index 851464bd..b261d8a7 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 { WorkspaceTools } from "../src/workspace-tools.ts";
import { recordingFixture } from "../../test-support/recording-fixtures.ts";
@@ -115,8 +116,9 @@ 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 panels = new WorkspaceTools({ context: context as PanelContext, deviceApps, logs: panel, performance, storage, tracesift });
+ const panels = new WorkspaceTools({ context: context as PanelContext, deviceApps, logs: panel, performance, storage, "app-intel": appIntel, tracesift });
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 18b8bd40..fe692a8c 100644
--- a/packages/ui/test/ui-telemetry.test.ts
+++ b/packages/ui/test/ui-telemetry.test.ts
@@ -719,3 +719,45 @@ 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.atlas_layout", 18);
+ api.recordUiTiming("ui.app_intel.flow_layout", 4);
+ api.setUiGauge("ui.app_intel.atlas_cells", 812);
+ api.setUiGauge("ui.app_intel.flow_nodes", 37);
+ api.countUiEvent("ui.app_intel.view_switch");
+ api.countUiEvent("ui.app_intel.zoom");
+ api.countUiEvent("ui.app_intel.boundary_filter");
+ await api.stopUiTelemetry();
+
+ const encoded = bodies.join("\n");
+ contains(encoded, "ui.app_intel.atlas_layout.mean");
+ contains(encoded, "ui.app_intel.flow_layout.mean");
+ contains(encoded, "ui.app_intel.atlas_cells");
+ contains(encoded, "ui.app_intel.flow_nodes");
+ contains(encoded, "ui.app_intel.view_switch");
+ contains(encoded, "ui.app_intel.zoom");
+ 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 91eacd52..6a79849e 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"];
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 44ad7ccd..d9901ae8 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 Intelligence", "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 0000000000000000000000000000000000000000..acbc44651bcc3d94609c00b1a6918066dd68fd5a
GIT binary patch
literal 29545
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
literal 0
HcmV?d00001
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 0000000000000000000000000000000000000000..a2133166bbf91df91aa14455a81e1c6982f11d71
GIT binary patch
literal 28834
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