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 + {graph.levels.map((level, depth) => level.length > 0 && + {depth === 0 ? "ROOT" : `HOP ${depth}`} + )} + {[...spots].map(([ref, spot]) => { + const from = graph.parent.get(ref); + const origin = from && spots.get(from); + if (!origin) return null; + const x1 = origin.x + CARD.width, y1 = origin.y + CARD.height / 2, x2 = spot.x, y2 = spot.y + CARD.height / 2; + const bend = Math.max(8, (x2 - x1) * 0.45); + const on = path.has(ref); + return ; + })} + {overflow.map(spot => + + +{spot.hidden} more at this hop + )} + {[...spots].map(([ref, spot]) => { + const reach = reachOf(snapshot, ref); + const screen = ref.startsWith("screen:") ? snapshot.screens[ref.slice(7)] : null; + const platforms = screen ? "both" as NodePlatforms : platformsFor(snapshot, ref); + const boundary = boundaryOf(ref); + const detail = `${boundaryLabel[boundary]} · hop ${spot.depth}${screen ? ` · ${screen.route ?? ref.slice(7)}` : ` · reach ${reach}`}${platforms === "both" ? "" : ` · ${platformLabel(platforms)}`}`; + const barWidth = Math.max(3, 44 * heatT(reach, max)); + const on = path.has(ref); + return { setLit(ref); onHover({ title: ref.replace(/^(?:file|package|screen):/, ""), detail, x: event.clientX, y: event.clientY }); }} + onPointerLeave={() => { setLit(null); onHover(null); }} + onClick={() => onRoot(ref)} + onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onRoot(ref); } }} + > + + + {platforms !== "both" && } + {labelOf(ref).slice(0, 20)} + {boundary} + {(screen ? screen.route ?? "" : groupOf(ref)).slice(0, 24 - boundary.length)} + {!screen && <> + {reach} + + } + ; + })} + ; +} + +/** 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); + }}> +
+ + {snapshot &&
+ {(["atlas", "flow"] as const).map(view => )} +
} + setQuery(event.target.value)} /> + {state.busy === "generating" || state.busy === "cancelling" ? : } +
+ {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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 { test(`workspace`, async ({ openView }) => { diff --git a/tooling/server-bundle.mjs b/tooling/server-bundle.mjs index 0e9ae6ac..620c0cc9 100644 --- a/tooling/server-bundle.mjs +++ b/tooling/server-bundle.mjs @@ -16,8 +16,11 @@ import { execFileSync } from "node:child_process"; * dynamically become chunks beside it, found the same way. */ export async function buildServerBundles({ entryPoints, outdir, sentryDirectory, splitting = false }) { + const serverEntries = entryPoints.server && !entryPoints["app-intelligence-worker"] + ? { ...entryPoints, "app-intelligence-worker": "packages/core/src/app-intelligence/worker.ts" } + : entryPoints; const result = await build({ - entryPoints, outdir, outExtension: { ".js": ".mjs" }, bundle: true, splitting, + entryPoints: serverEntries, outdir, outExtension: { ".js": ".mjs" }, bundle: true, splitting, format: "esm", platform: "node", target: "node22", minify: false, legalComments: "eof", metafile: true, sourcemap: "external", plugins: [sentryEsbuildPlugin({ project: SENTRY_PROJECTS.server, telemetry: false, sourcemaps: { disable: true }, release: { inject: false, create: false, finalize: false } })], From eb476648fcdf743171e8cc8bc9d6377e31a363b6 Mon Sep 17 00:00:00 2001 From: Hur Ali Date: Thu, 5 Nov 2026 11:50:58 +0500 Subject: [PATCH 2/9] fix: file naming bugs --- docs/telemetry.md | 4 +- .../core/src/app-intelligence/analyzer.ts | 197 +++++++++++++++--- packages/core/src/app-intelligence/service.ts | 10 +- packages/core/src/app-intelligence/storage.ts | 29 ++- packages/core/src/app-intelligence/tools.ts | 15 +- packages/core/test/app-intelligence.test.ts | 78 ++++++- packages/shared/src/app-intelligence.ts | 3 +- packages/ui/src/app-intelligence-layout.ts | 89 ++++---- packages/ui/src/app-intelligence-panel.ts | 26 ++- .../src/components/app-intelligence-view.tsx | 132 ++++++++---- .../ui/test/app-intelligence-layout.test.ts | 58 +++--- .../ui/test/app-intelligence-view.test.ts | 64 +++++- 12 files changed, 536 insertions(+), 169 deletions(-) diff --git a/docs/telemetry.md b/docs/telemetry.md index 0c1619b7..f21c232f 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -665,7 +665,7 @@ layering — and `ui.app_intel.atlas_cells` and `ui.app_intel.flow_nodes` gauge 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 +entry-point 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/packages/core/src/app-intelligence/analyzer.ts b/packages/core/src/app-intelligence/analyzer.ts index 0851f3fc..8ef9dd66 100644 --- a/packages/core/src/app-intelligence/analyzer.ts +++ b/packages/core/src/app-intelligence/analyzer.ts @@ -1,6 +1,6 @@ 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 { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { parse } from "@babel/parser"; import enhancedResolve from "enhanced-resolve"; import type { AppIntelSnapshot, IntelDiagnostic, IntelEdge, IntelPlatform, IntelRef } from "@mobile-dev/shared/app-intelligence.ts"; @@ -15,7 +15,8 @@ const portable = (root: string, path: string) => relative(root, path).split(sep) 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 }> }; +type Registration = { name: string; local?: string; specifier?: string }; +type ParsedSource = { path: string; code: string; imports: Array<{ specifier: string; typeOnly: boolean; dynamic: boolean; local?: string }>; navigation: string[]; registrations: Registration[]; declared: Set }; function walkAst(node: unknown, visit: (node: Record) => void) { if (!node || typeof node !== "object") return; @@ -28,8 +29,29 @@ function walkAst(node: unknown, visit: (node: Record) => void) { } } +const isModuleCall = (node: Record) => + (node.type === "CallExpression" || node.type === "OptionalCallExpression") && + node.arguments?.[0]?.type === "StringLiteral" && + (node.callee?.type === "Import" || node.callee?.name === "require"); + +/** + * The component a `` renders, however it was written: `component={Screen}`, + * `getComponent={() => Screen}`, `component={lazy(() => import('…'))}` and + * `getComponent={() => require('…').Screen}` all name the same file. A specifier beats a local + * name because `require('…').Screen` carries both and only the specifier names the module. + */ +function componentRef(expression: unknown): Pick { + let local: string | undefined, specifier: string | undefined; + walkAst(expression, node => { + if (specifier) return; + if (isModuleCall(node)) specifier = node.arguments[0].value; + else if (!local && node.type === "Identifier" && node.name !== "require") local = node.name; + }); + return specifier ? { specifier } : { local }; +} + function parseSource(path: string, code: string, diagnostics: IntelDiagnostic[]): ParsedSource { - const found: ParsedSource = { path, code, imports: [], navigation: [], registrations: [] }; + const found: ParsedSource = { path, code, imports: [], navigation: [], registrations: [], declared: new Set() }; try { const ast = parse(code, { sourceType: "unambiguous", errorRecovery: true, plugins: ["jsx", "typescript", "decorators-legacy"] }); walkAst(ast, node => { @@ -38,17 +60,21 @@ function parseSource(path: string, code: string, diagnostics: IntelDiagnostic[]) } 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; + // `require` counts: React Navigation registers plenty of screens through one. + if (isModuleCall(node)) found.imports.push({ specifier: node.arguments[0].value, typeOnly: false, dynamic: true }); + const method = node.callee?.property?.name ?? node.callee?.name; if (["navigate", "push", "replace"].includes(method)) found.navigation.push(node.arguments[0].value); + } else if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") { + if (typeof node.id?.name === "string") found.declared.add(node.id.name); + } else if (node.type === "VariableDeclarator" && typeof node.id?.name === "string") { + found.declared.add(node.id.name); } else if (node.type === "JSXOpeningElement") { const name = node.name?.type === "JSXMemberExpression" ? node.name.property?.name : node.name?.name; if (name !== "Screen") return; const attrs = new Map((node.attributes ?? []).filter((item: any) => item.type === "JSXAttribute").map((item: any) => [item.name?.name, item.value])); const screenName = (attrs.get("name") as any)?.value; - const component = (attrs.get("component") as any)?.expression?.name; - if (typeof screenName === "string") found.registrations.push({ name: screenName, local: component }); + const component = (attrs.get("component") ?? attrs.get("getComponent")) as any; + if (typeof screenName === "string") found.registrations.push({ name: screenName, ...componentRef(component?.expression) }); } }); } catch (error) { @@ -57,6 +83,89 @@ function parseSource(path: string, code: string, diagnostics: IntelDiagnostic[]) return found; } +/** tsconfig.json is JSONC: comments and trailing commas are legal there and fatal to `JSON.parse`. */ +export function parseJsonc(text: string): any { + let out = "", inString = false, escaped = false, inLine = false, inBlock = false; + for (let index = 0; index < text.length; index++) { + const char = text[index], next = text[index + 1]; + if (inLine) { if (char === "\n") { inLine = false; out += char; } continue; } + if (inBlock) { if (char === "*" && next === "/") { inBlock = false; index++; } continue; } + if (inString) { + out += char; + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === "/" && next === "/") { inLine = true; continue; } + if (char === "/" && next === "*") { inBlock = true; index++; continue; } + if (char === '"') inString = true; + out += char; + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1")); +} + +/** `extends` targets are a relative path or a package, with `.json` and `/tsconfig.json` implied. */ +async function resolveExtends(from: string, target: string): Promise { + const candidates: string[] = []; + const add = (base: string) => { + candidates.push(base); + if (!extname(base)) candidates.push(`${base}.json`, join(base, "tsconfig.json")); + }; + if (target.startsWith(".") || isAbsolute(target)) add(resolve(from, target)); + else for (let directory = from; ; directory = dirname(directory)) { + add(join(directory, "node_modules", target)); + if (dirname(directory) === directory) break; + } + for (const candidate of candidates) try { return await realpath(candidate); } catch { /* try the next shape */ } + return null; +} + +type Alias = { prefix: string; suffix: string; targets: string[] }; + +/** + * Path aliases from the tsconfig chain. The nearest config that declares `paths` wins, and its + * relative targets resolve against its own directory (or its `baseUrl`), as TypeScript does. + */ +async function readAliases(root: string, diagnostics: IntelDiagnostic[]): Promise { + const aliases: Alias[] = []; + const seen = new Set(); + let file: string | null = join(root, "tsconfig.json"); + for (let depth = 0; file && depth < 10; depth++) { + if (seen.has(file)) break; + seen.add(file); + let config: any; + try { + config = parseJsonc(await readFile(file, "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") diagnostics.push({ code: "tsconfig", file: portable(root, file), message: "Could not read tsconfig path aliases; normal module resolution was used." }); + return aliases; + } + const paths = config.compilerOptions?.paths; + if (paths && typeof paths === "object") { + const base = resolve(dirname(file), config.compilerOptions?.baseUrl ?? "."); + for (const [pattern, targets] of Object.entries(paths)) { + if (!Array.isArray(targets)) continue; + const star = pattern.indexOf("*"); + aliases.push({ prefix: star < 0 ? pattern : pattern.slice(0, star), suffix: star < 0 ? "" : pattern.slice(star + 1), targets: targets.filter((value): value is string => typeof value === "string").map(value => resolve(base, value)) }); + } + return aliases; + } + const parent = Array.isArray(config.extends) ? config.extends.at(-1) : config.extends; + file = typeof parent === "string" ? await resolveExtends(dirname(file), parent) : null; + } + return aliases; +} + +/** The package a resolved path belongs to, from the last `node_modules` segment it sits under. */ +function packageFrom(path: string, specifier: string): string { + const segments = path.split(sep); + const marker = segments.lastIndexOf("node_modules"); + if (marker < 0 || !segments[marker + 1]) return packageName(specifier); + const first = segments[marker + 1]; + return first.startsWith("@") && segments[marker + 2] ? `${first}/${segments[marker + 2]}` : first; +} + function expoRoute(root: string, path: string): { id: string; title: string; route: string } | null { const rel = portable(root, path); const marker = rel.match(/(?:^|\/)(app)\/(.+)\.(?:[cm]?[jt]sx?)$/); @@ -96,18 +205,7 @@ export async function analyzeApp(projectPath: string, options: { signal?: AbortS 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 aliases = await readAliases(root, diagnostics); const requestsFor = (specifier: string) => { const requests = [specifier]; for (const alias of aliases) if (specifier.startsWith(alias.prefix) && specifier.endsWith(alias.suffix)) { @@ -118,33 +216,59 @@ export async function analyzeApp(projectPath: string, options: { signal?: AbortS }; const resolveFor = new Map string | false>(); for (const platform of ["ios", "android"] as const) resolveFor.set(platform, enhancedResolve.create.sync({ extensions: extensions(platform), mainFields: ["react-native", "module", "main"], conditionNames: ["react-native", platform, "import", "require", "default"], symlinks: false })); + const realCache = new Map(); + const realOf = async (path: string) => { + const cached = realCache.get(path); + if (cached) return cached; + let real = path; + try { real = await realpath(path); } catch { /* a virtual or missing target keeps its own path */ } + realCache.set(path, real); + return real; + }; const edges: IntelEdge[] = []; const packages: AppIntelSnapshot["packages"] = Object.create(null); - const importTargets = new Map>(); + const localTargets = new Map>(); + const specifierTargets = new Map>(); for (const source of parsed.values()) { const locals = new Map(); + const specifiers = new Map(); for (const item of source.imports) { check(); const resolved = new Map(); for (const platform of ["ios", "android"] as const) { - for (const request of requestsFor(item.specifier)) try { - const result = resolveFor.get(platform)!(resolve(root, dirname(source.path)), request); - if (!result || !inside(root, result)) continue; - const target = portable(root, result); + for (const request of requestsFor(item.specifier)) { + let result: string | false; + try { result = resolveFor.get(platform)!(resolve(root, dirname(source.path)), request); } + catch { continue; /* try the next alias, or report it as a diagnostic below */ } + if (!result) continue; + // A workspace package is symlinked into node_modules but its real path is app code, so + // the real path decides, and everything else collapses to one leaf for its package. + const real = await realOf(result); + const appCode = inside(root, real) && !relative(root, real).split(sep).includes("node_modules"); + if (!appCode) { + const name = packageFrom(result, item.specifier); + packages[name] ??= { name, reach: 0, entry: inside(root, result) ? portable(root, result) : null }; + const key = packageRef(name); + resolved.set(key, [...(resolved.get(key) ?? []), platform]); + break; + } + const target = portable(root, real); const key = fileRef(target); resolved.set(key, [...(resolved.get(key) ?? []), platform]); if (item.local) locals.set(item.local, target); + specifiers.set(item.specifier, target); break; - } 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 }; + if (resolved.size === 0 && !item.specifier.startsWith(".") && !item.specifier.startsWith("/") && !item.specifier.startsWith("#")) { + const name = packageName(item.specifier); packages[name] ??= { name, reach: 0, entry: null }; resolved.set(packageRef(name), ["ios", "android"]); } if (resolved.size === 0) diagnostics.push({ code: "unresolved-import", file: source.path, message: `Could not resolve ${item.specifier} for iOS or Android.` }); for (const [to, platforms] of resolved) edges.push({ from: fileRef(source.path), to: to as any, kind: "import", specifier: item.specifier, platforms, dynamic: item.dynamic || undefined, typeOnly: item.typeOnly || undefined }); } - importTargets.set(source.path, locals); + localTargets.set(source.path, locals); + specifierTargets.set(source.path, specifiers); } const screens: AppIntelSnapshot["screens"] = Object.create(null); const screenByName = new Map(); @@ -156,8 +280,15 @@ export async function analyzeApp(projectPath: string, options: { signal?: AbortS } 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: [] }; + const target = registration.specifier ? specifierTargets.get(path)?.get(registration.specifier) + : registration.local ? localTargets.get(path)?.get(registration.local) : undefined; + // Falling back to the registering file would give every screen that file's whole import + // graph, so an unresolved component is reported rather than papered over. + // A nested navigator declared in this very file really does live here; only a component + // this file neither declares nor imports is a gap worth reporting. + const own = registration.local ? source.declared.has(registration.local) : false; + if (!target && !own) diagnostics.push({ code: "unresolved-screen", file: path, message: `Could not tell which file renders ${registration.name}; it is mapped to its navigator.` }); + screens[id] = { title: registration.name, route: null, source: target ?? path, layouts: [] }; screenByName.set(registration.name.toLowerCase(), id); } } @@ -192,11 +323,11 @@ export async function analyzeApp(projectPath: string, options: { signal?: AbortS 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++; } + // A package is a leaf: its own internals belong to its author, not to this app's hierarchy. + else if (edge.to.startsWith("package:")) { const pkg = packages[edge.to.slice(8)]; if (pkg) pkg.reach++; continue; } queue.push(edge.to); } } const sourceFingerprint = fingerprint(Object.entries(files).map(([path, file]) => `${path}:${file.fingerprint}`).join("\n")); return { formatVersion: 1, meta: { projectId, generation, analyzedAt: new Date().toISOString(), analyzerVersion: "mobile-dev-1", sourceFingerprint }, files, packages, screens, edges: unique, diagnostics, indexes }; } - diff --git a/packages/core/src/app-intelligence/service.ts b/packages/core/src/app-intelligence/service.ts index b74c7556..093a3521 100644 --- a/packages/core/src/app-intelligence/service.ts +++ b/packages/core/src/app-intelligence/service.ts @@ -1,7 +1,7 @@ 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"; +import { readAppIntel, readRecentProjects, readRememberedProject, rememberProject, validateProjectPath, writeAppIntel } from "./storage.ts"; type Run = NonNullable & { controller: AbortController; worker?: Worker }; @@ -32,10 +32,14 @@ export class AppIntelligenceService { 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 snapshot(path?: string, remember = false): Promise<{ projectPath: string; snapshot: AppIntelSnapshot | null; recents: string[] }> { + const projectPath = await this.project(path, remember); + return { projectPath, snapshot: await readAppIntel(projectPath), recents: await readRecentProjects() }; } + /** The projects offered when nothing is selected yet, so the picker is never empty on a cold start. */ + async recents(): Promise { return await readRecentProjects(); } + async generate(path?: string, signal?: AbortSignal): Promise<{ projectPath: string; snapshot: AppIntelSnapshot }> { const projectPath = await this.project(path, true); if (this.runs.has(projectPath)) throw new Error("App Intelligence generation is already running for this project."); diff --git a/packages/core/src/app-intelligence/storage.ts b/packages/core/src/app-intelligence/storage.ts index 829502f3..183d94d1 100644 --- a/packages/core/src/app-intelligence/storage.ts +++ b/packages/core/src/app-intelligence/storage.ts @@ -22,12 +22,35 @@ async function atomicWrite(path: string, content: string) { await rename(temporary, path); } +/** How many projects the picker offers. Enough to switch between apps, short enough to scan. */ +const RECENT_LIMIT = 8; +export type AppIntelPreferences = { projectPath: string | null; recents: string[] }; + +export async function readPreferences(): Promise { + try { + const value = JSON.parse(await readFile(preferencePath, "utf8")); + const projectPath = typeof value.projectPath === "string" ? value.projectPath : null; + const stored = Array.isArray(value.recents) ? value.recents.filter((entry: unknown): entry is string => typeof entry === "string") : []; + // An older preferences file holds one path and no list, so the chosen project seeds the list. + const recents = [...new Set([...(projectPath ? [projectPath] : []), ...stored])].slice(0, RECENT_LIMIT); + return { projectPath, recents }; + } catch { return { projectPath: null, recents: [] }; } +} + export async function readRememberedProject(): Promise { - try { const value = JSON.parse(await readFile(preferencePath, "utf8")); return typeof value.projectPath === "string" ? value.projectPath : null; } - catch { return null; } + return (await readPreferences()).projectPath; } -export async function rememberProject(projectPath: string) { await atomicWrite(preferencePath, JSON.stringify({ projectPath })); } +export async function readRecentProjects(): Promise { + return (await readPreferences()).recents; +} + +/** Most recent first, deduplicated, so the picker reflects the order projects were last opened. */ +export async function rememberProject(projectPath: string) { + const { recents } = await readPreferences(); + const next = [projectPath, ...recents.filter(entry => entry !== projectPath)].slice(0, RECENT_LIMIT); + await atomicWrite(preferencePath, JSON.stringify({ projectPath, recents: next })); +} export const snapshotPath = (projectPath: string) => join(APP_INTEL_DIRECTORY, `${projectKey(projectPath)}.json`); export async function readAppIntel(projectPath: string): Promise { diff --git a/packages/core/src/app-intelligence/tools.ts b/packages/core/src/app-intelligence/tools.ts index 416e76c8..fd5364e7 100644 --- a/packages/core/src/app-intelligence/tools.ts +++ b/packages/core/src/app-intelligence/tools.ts @@ -55,13 +55,26 @@ export function registerAppIntelligenceTools(server: McpServer, service: AppInte } catch (error) { recordAppIntelOperation("pick", "failed", performance.now() - startedAt); return failure(error); } }); + registerAppTool(server, "mobile_select_app_intel_project", { + title: "Switch App Intelligence project", description: "Switch App Intelligence to a project the panel already knows, and remember it as the current one.", + inputSchema: { projectPath: z.string().min(1).max(4096) }, annotations: write, _meta: { ui: { visibility: ["app"] } }, + }, async input => { + const startedAt = performance.now(); + try { const result = text(await service.snapshot(input.projectPath, true)); recordAppIntelOperation("pick", "success", performance.now() - startedAt); return result; } + catch (error) { recordAppIntelOperation("pick", "failed", performance.now() - startedAt); return failure(error); } + }); + registerAppTool(server, "mobile_app_intel_snapshot", { title: "Read App Intelligence snapshot", description: "Read the selected App Intelligence snapshot for the shared visualization.", inputSchema: generateAppIntelInput.shape, annotations: read, _meta: { ui: { visibility: ["app"] } }, }, async input => { const startedAt = performance.now(); try { const result = text(await service.snapshot(input.projectPath)); recordAppIntelOperation("snapshot", "success", performance.now() - startedAt); return result; } - catch (error) { recordAppIntelOperation("snapshot", "failed", performance.now() - startedAt); return failure(error); } + catch (error) { + recordAppIntelOperation("snapshot", "failed", performance.now() - startedAt); + const recents = await service.recents().catch(() => []); + return recents.length ? { ...failure(error), structuredContent: { recents } } : failure(error); + } }); registerAppTool(server, "mobile_app_intel_status", { diff --git a/packages/core/test/app-intelligence.test.ts b/packages/core/test/app-intelligence.test.ts index 94c3527a..72637f64 100644 --- a/packages/core/test/app-intelligence.test.ts +++ b/packages/core/test/app-intelligence.test.ts @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { Worker } from "node:worker_threads"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { analyzeApp } from "../src/app-intelligence/analyzer.ts"; import { lookupAppIntel } from "../src/app-intelligence/lookup.ts"; @@ -83,3 +85,77 @@ test("App Intelligence counts how many screens reach each file", async () => { assert.equal(snapshot.files["app/index.tsx"].reach, 1, "a screen reaches its own source"); } finally { await rm(root, { recursive: true, force: true }); } }); + +test("App Intelligence reads JSONC path aliases and gives each screen its own component", async () => { + const root = await mkdtemp(join(tmpdir(), "mobile-dev-app-intel-alias-")); + try { + await mkdir(join(root, "src", "screens"), { recursive: true }); + await mkdir(join(root, "node_modules", "@react-native", "typescript-config"), { recursive: true }); + await mkdir(join(root, "node_modules", "react-native-screens", "lib"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "alias-fixture" })); + // Comments and trailing commas are legal in tsconfig.json, and the aliases live one `extends` up. + await writeFile(join(root, "node_modules", "@react-native", "typescript-config", "tsconfig.json"), + '{\n "compilerOptions": {\n // the app aliases its own source\n "baseUrl": "../../..",\n "paths": {"#/*": ["./src/*"],},\n },\n}\n'); + await writeFile(join(root, "tsconfig.json"), '{\n /* inherited */\n "extends": "@react-native/typescript-config/tsconfig.json",\n}\n'); + await writeFile(join(root, "node_modules", "react-native-screens", "package.json"), JSON.stringify({ name: "react-native-screens", main: "lib/index.js" })); + await writeFile(join(root, "node_modules", "react-native-screens", "lib", "index.js"), "require('./deep'); module.exports = {}\n"); + await writeFile(join(root, "node_modules", "react-native-screens", "lib", "deep.js"), "module.exports = {}\n"); + await writeFile(join(root, "src", "screens", "Settings.tsx"), "import { helper } from '#/util/helper'; export function SettingsScreen(){ return helper }\n"); + await writeFile(join(root, "src", "screens", "Profile.tsx"), "export function ProfileScreen(){ return null }\n"); + await mkdir(join(root, "src", "util"), { recursive: true }); + await writeFile(join(root, "src", "util", "helper.ts"), "export const helper = 1\n"); + await writeFile(join(root, "src", "Navigation.tsx"), [ + "import 'react-native-screens'", + "import { SettingsScreen } from '#/screens/Settings'", + "function TabsNavigator(){ return null }", + "export default () => <> SettingsScreen} />", + " require('#/screens/Profile').ProfileScreen} />", + " TabsNavigator} />", + ].join("\n") + "\n"); + + const snapshot = await analyzeApp(root); + assert.equal(snapshot.screens["navigation:Settings"].source, "src/screens/Settings.tsx", "getComponent names the screen's own file"); + assert.equal(snapshot.screens["navigation:Profile"].source, "src/screens/Profile.tsx", "a require() inside getComponent resolves too"); + assert.equal(snapshot.screens["navigation:Tabs"].source, "src/Navigation.tsx", "a navigator declared in place belongs to its own file"); + assert.deepEqual(snapshot.diagnostics.filter(entry => entry.code === "unresolved-screen"), []); + assert.ok(!("#" in snapshot.packages), "an aliased '#/' import is app code, not a package named '#'"); + assert.equal(snapshot.files["src/util/helper.ts"].reach, 2, "the alias is followed into the app's own source, from Settings and from the navigator"); + // A dependency is one leaf: its entry file is recorded, its internals never enter the graph. + assert.equal(snapshot.packages["react-native-screens"].entry, "node_modules/react-native-screens/lib/index.js"); + assert.deepEqual(Object.keys(snapshot.files).filter(path => path.includes("node_modules/")), []); + assert.deepEqual(snapshot.edges.filter(edge => edge.to?.includes("node_modules/")), []); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("App Intelligence treats a workspace package behind a node_modules symlink as app code", async () => { + const root = await mkdtemp(join(tmpdir(), "mobile-dev-app-intel-workspace-")); + try { + await mkdir(join(root, "app"), { recursive: true }); + await mkdir(join(root, "modules", "ui", "src"), { recursive: true }); + await mkdir(join(root, "node_modules"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "workspace-fixture" })); + await writeFile(join(root, "modules", "ui", "package.json"), JSON.stringify({ name: "ui", main: "src/index.ts" })); + await writeFile(join(root, "modules", "ui", "src", "index.ts"), "export const button = 1\n"); + await symlink(join(root, "modules", "ui"), join(root, "node_modules", "ui"), "dir"); + await writeFile(join(root, "app", "index.tsx"), "import { button } from 'ui'; export default function Home(){ return button }\n"); + + const snapshot = await analyzeApp(root); + assert.ok(!("ui" in snapshot.packages), "a workspace package is this app's own code"); + assert.equal(snapshot.files["modules/ui/src/index.ts"].reach, 1); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("the project picker remembers recent projects, most recent first", async () => { + // Preferences live under the home directory, so the check runs against a throwaway one. + const home = await mkdtemp(join(tmpdir(), "mobile-dev-app-intel-home-")); + try { + const storage = new URL("../src/app-intelligence/storage.ts", import.meta.url).href; + const script = [ + `const { readPreferences, rememberProject } = await import(${JSON.stringify(storage)});`, + "await rememberProject('/one'); await rememberProject('/two'); await rememberProject('/one');", + "process.stdout.write(JSON.stringify(await readPreferences()));", + ].join("\n"); + const { stdout } = await promisify(execFile)(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", script], { env: { ...process.env, HOME: home } }); + assert.deepEqual(JSON.parse(stdout), { projectPath: "/one", recents: ["/one", "/two"] }); + } finally { await rm(home, { recursive: true, force: true }); } +}); diff --git a/packages/shared/src/app-intelligence.ts b/packages/shared/src/app-intelligence.ts index 4ae494ae..709a3996 100644 --- a/packages/shared/src/app-intelligence.ts +++ b/packages/shared/src/app-intelligence.ts @@ -20,7 +20,8 @@ export type AppIntelSnapshot = { formatVersion: 1; meta: { projectId: string; generation: string; analyzedAt: string; analyzerVersion: string; sourceFingerprint: string }; files: Record; - packages: Record; + /** Third-party dependencies, kept as leaves: `entry` is the file that resolved, not a subtree. */ + packages: Record; screens: Record; edges: IntelEdge[]; diagnostics: IntelDiagnostic[]; diff --git a/packages/ui/src/app-intelligence-layout.ts b/packages/ui/src/app-intelligence-layout.ts index 1005a12a..a327bf87 100644 --- a/packages/ui/src/app-intelligence-layout.ts +++ b/packages/ui/src/app-intelligence-layout.ts @@ -141,7 +141,7 @@ 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 { +export function buildFlow(snapshot: AppIntelSnapshot, start: IntelRef, caps: number[] = FLOW_CAPS): FlowGraph { const levels: IntelRef[][] = [[start]]; const hidden = [0, 0, 0, 0]; const parent = new Map(); @@ -151,8 +151,6 @@ export function buildFlow(snapshot: AppIntelSnapshot, start: IntelRef, caps: num 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); @@ -193,9 +191,40 @@ export function groupOf(ref: IntelRef): string { return ref.startsWith("package:") ? PACKAGE_PREFIX : path.split("/").slice(0, 2).join("/"); } +const INDEX_FILE = /^index\.[cm]?[jt]sx?$/; + +/** + * What to call a node. `index.tsx` names nothing, so a barrel file is named for the folder it + * indexes: `src/components/forms/Toggle/index.tsx` reads as "Toggle", the way the import does. + */ export function labelOf(ref: IntelRef): string { const path = ref.replace(/^(?:file|package|screen):/, ""); - return ref.startsWith("package:") ? path : path.split("/").pop() ?? path; + if (ref.startsWith("package:")) return path; + const parts = path.split("/"); + const name = parts.pop() ?? path; + return INDEX_FILE.test(name) && parts.length ? parts[parts.length - 1] : name; +} + +/** + * Where that node lives, in full. `groupOf` keeps two segments because it clusters a column; + * a label needs the whole path, since several `index.tsx` differ by nothing else. + */ +export function pathOf(ref: IntelRef): string { + if (ref.startsWith("package:")) return PACKAGE_PREFIX; + const parts = ref.replace(/^(?:file|screen):/, "").split("/"); + const name = parts.pop() ?? ""; + if (INDEX_FILE.test(name) && parts.length) parts.pop(); + return parts.join("/"); +} + +/** Truncates a path from the left: the tail is what tells two of them apart. */ +export function ellipsize(text: string, max: number): string { + return text.length <= max ? text : `…${text.slice(text.length - max + 1)}`; +} + +/** Truncates a name from the right, where the distinguishing part comes first. */ +export function clampName(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; } const RAMP = ["#1d4a57", "#1f7d82", "#2fa37f", "#79b85a", "#d7c045", "#e09440", "#dc5440"] as const; @@ -248,17 +277,20 @@ export function depthTint(depth: number, alpha: number): string { * 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 const boundaries = ["entry", "navigation", "hook", "component", "state", "api", "model", "native", "asset", "util", "package", "other"] as const; export type Boundary = typeof boundaries[number]; +/** "entry" rather than "screen": the analyzer's roots include lexicons and routes that render nothing. */ export const boundaryLabel: Record = { - screen: "Screens", navigation: "Navigation", hook: "Hooks", component: "Components", state: "State", - api: "API & data", model: "Types & models", native: "Native", asset: "Assets", util: "Utilities", package: "Packages", + entry: "Entry points", navigation: "Navigation", hook: "Hooks", component: "Components", state: "State", + api: "API & data", model: "Types & schemas", native: "Native", asset: "Assets", util: "Utilities", + package: "Packages", other: "Other source", }; const BOUNDARY_COLOR: Record = { - screen: "#8b5cf6", navigation: "#6366f1", hook: "#0ea5e9", component: "#14b8a6", state: "#f97316", - api: "#ef4444", model: "#a855f7", native: "#eab308", asset: "#84cc16", util: "#64748b", package: "#94a3b8", + entry: "#8b5cf6", navigation: "#6366f1", hook: "#0ea5e9", component: "#14b8a6", state: "#f97316", + api: "#ef4444", model: "#a855f7", native: "#eab308", asset: "#84cc16", util: "#64748b", + package: "#94a3b8", other: "#78839b", }; export const boundaryColor = (boundary: Boundary): string => BOUNDARY_COLOR[boundary]; @@ -272,8 +304,8 @@ const FOLDER_ROLES: ReadonlyArray = [ ["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"]], + ["model", ["type", "model", "schema", "entity", "dto", "lexicon", "def", "generated", "proto", "graphql-generated"]], + ["entry", ["screen", "page", "scene", "view"]], ["component", ["component", "ui", "widget", "element", "layout", "primitive"]], ["asset", ["asset", "image", "img", "font", "media", "icon"]], ["util", ["util", "helper", "lib", "shared", "common"]], @@ -297,9 +329,12 @@ const FILE_ROLES: ReadonlyArray = [ /** 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("screen:")) return "entry"; if (ref.startsWith("package:")) return "package"; const path = ref.slice(5); + // A dependency inside node_modules is a package however the analyzer recorded it, and its own + // folder names describe its author's layout, not this app's, so no other rule may read them. + if (path === "node_modules" || path.includes("node_modules/")) return "package"; const name = path.split("/").pop() ?? path; const base = name.replace(/\.[^.]+$/, "").replace(/\.(?:ios|android|native|web)$/i, ""); if (NATIVE_EXTENSIONS.test(name)) return "native"; @@ -312,34 +347,12 @@ export function boundaryOf(ref: IntelRef): Boundary { if (role) return role; } for (const [boundary, pattern] of FILE_ROLES) if (pattern.test(base)) return boundary; - return /^[A-Z]/.test(base) ? "component" : "util"; + return /^[A-Z]/.test(base) ? "component" : "other"; } -/** 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 }> { +/** The boundaries a set of nodes falls into, in display order, with how many nodes each holds. */ +export function boundariesIn(values: Iterable): Array<{ boundary: Boundary; count: number }> { const counts = new Map(); - for (const ref of refs) counts.set(boundaryOf(ref), (counts.get(boundaryOf(ref)) ?? 0) + 1); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); return boundaries.filter(boundary => counts.has(boundary)).map(boundary => ({ boundary, count: counts.get(boundary) as number })); } - -/** - * 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 index c16a3557..41c46e60 100644 --- a/packages/ui/src/app-intelligence-panel.ts +++ b/packages/ui/src/app-intelligence-panel.ts @@ -6,6 +6,8 @@ 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; + /** Projects the panel remembers, most recent first, so switching needs no folder picker. */ + recents: string[]; 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; @@ -17,7 +19,7 @@ function resultError(result: Awaited>) 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 state: AppIntelligenceState = { open: false, available: false, projectPath: "", snapshot: null, run: null, busy: null, error: "", selectedScreen: null, recents: [], 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); }; @@ -27,7 +29,7 @@ export class AppIntelligencePanel { 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 }); } + selectScreen(selectedScreen: string | null) { this.update({ selectedScreen, flowRoot: null, view: "flow" }); } 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 }); } @@ -44,14 +46,15 @@ export class AppIntelligencePanel { 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 }; + // Even a failed read carries the remembered projects, so the picker still has them to offer. + if (result.isError) { this.update({ recents: (result.structuredContent as { recents?: string[] } | undefined)?.recents ?? this.state.recents }); throw new Error(resultError(result)); } + const value = result.structuredContent as { projectPath: string; snapshot: AppIntelSnapshot | null; recents?: string[] }; setUiGauge("ui.app_intel.files", Object.keys(value.snapshot?.files ?? {}).length); setUiGauge("ui.app_intel.screens", Object.keys(value.snapshot?.screens ?? {}).length); const screen = value.snapshot && this.state.selectedScreen && value.snapshot.screens[this.state.selectedScreen] ? this.state.selectedScreen : null; 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 }); + this.update({ projectPath: value.projectPath, snapshot: value.snapshot, recents: value.recents ?? this.state.recents, 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 }); @@ -71,6 +74,19 @@ export class AppIntelligencePanel { } catch (error) { this.update({ busy: null, error: error instanceof Error ? error.message : String(error) }); captureUiError(error, "app_intel.pick"); } } + /** Switches to a remembered project without reopening the host folder picker. */ + async selectProject(projectPath: string) { + if (!this.state.available || projectPath === this.state.projectPath) return; + this.update({ busy: "picking", error: "", snapshot: null, selectedScreen: null, flowRoot: null, atlasPath: "", view: "atlas", boundaries: null }); + try { + const result = await this.app.callServerTool({ name: "mobile_select_app_intel_project", arguments: { projectPath } }); + if (result.isError) throw new Error(resultError(result)); + const value = result.structuredContent as { projectPath: string; snapshot: AppIntelSnapshot | null; recents?: string[] }; + countUiEvent("ui.app_intel.project_switch"); + this.update({ projectPath: value.projectPath, snapshot: value.snapshot, recents: value.recents ?? this.state.recents, busy: null }); + } catch (error) { this.update({ busy: null, error: error instanceof Error ? error.message : String(error) }); captureUiError(error, "app_intel.select"); } + } + async generate() { if (!this.state.available || !this.state.projectPath) return; this.update({ busy: "generating", error: "", run: { runId: "ui", phase: "analyzing", message: "Discovering screens and dependencies" } }); diff --git a/packages/ui/src/components/app-intelligence-view.tsx b/packages/ui/src/components/app-intelligence-view.tsx index 2c27d50a..91535696 100644 --- a/packages/ui/src/components/app-intelligence-view.tsx +++ b/packages/ui/src/components/app-intelligence-view.tsx @@ -5,11 +5,34 @@ 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 { boundariesIn, boundaryColor, boundaryLabel, boundaryOf, buildFlow, buildTrie, clampName, depthHue, depthTint, ellipsize, findNode, heat, heatInk, heatRamp, heatT, labelOf, leavesOf, ancestorsOf, maxReach, ownerScreen, pathOf, 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"; +import { NativeSelect, NativeSelectOption } from "./ui/native-select.tsx"; + +const BROWSE = "\u0000browse"; + +/** + * The project picker. It remembers what has been opened, so switching between apps is a dropdown + * rather than a trip through the host folder picker; the last entry still opens that picker. + */ +function ProjectPicker({ projectPath, recents, disabled, onSelect, onBrowse }: { + projectPath: string; recents: readonly string[]; disabled: boolean; + onSelect: (path: string) => void; onBrowse: () => void; +}) { + const options = [...new Set([...(projectPath ? [projectPath] : []), ...recents])]; + if (!options.length) return ; + return

+ + event.target.value === BROWSE ? onBrowse() : onSelect(event.target.value)}> + {options.map(path => {path.split("/").at(-1) || path})} + Choose another project… + +
; +} 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" }; @@ -35,18 +58,24 @@ function useMeasured() { } /** - * 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. + * Drag-to-pan and wheel-pan for the Flow. The board usually fits its pane, so moving scrollLeft + * did nothing; this translates the canvas instead, which pans whatever its size. A drag that + * actually moves swallows the click that follows it, so panning never re-roots the graph. */ -function useDragPan(element: HTMLElement | null) { +function usePanCanvas(element: HTMLElement | null, resetKey: string) { useEffect(() => { if (!element) return; - let origin: { x: number; y: number; left: number; top: number } | null = null; + const canvas = element.firstElementChild as HTMLElement | null; + if (!canvas) return; + let x = 0, y = 0; + const draw = () => { canvas.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`; }; + draw(); + let origin: { x: number; y: number; panX: number; panY: number } | null = null; let moved = false; const swallow = (event: Event) => { event.preventDefault(); event.stopPropagation(); }; const down = (event: PointerEvent) => { if (event.button !== 0) return; - origin = { x: event.clientX, y: event.clientY, left: element.scrollLeft, top: element.scrollTop }; + origin = { x: event.clientX, y: event.clientY, panX: x, panY: y }; moved = false; }; const move = (event: PointerEvent) => { @@ -55,8 +84,9 @@ function useDragPan(element: HTMLElement | null) { 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; + x = origin.panX + dx; + y = origin.panY + dy; + draw(); }; const up = () => { if (moved) element.addEventListener("click", swallow, { capture: true, once: true }); @@ -64,17 +94,28 @@ function useDragPan(element: HTMLElement | null) { moved = false; delete element.dataset.panning; }; + const wheel = (event: WheelEvent) => { + event.preventDefault(); + x -= event.deltaX; + y -= event.deltaY; + draw(); + }; + const recenter = () => { x = 0; y = 0; draw(); }; element.addEventListener("pointerdown", down); + element.addEventListener("wheel", wheel, { passive: false }); + element.addEventListener("dblclick", recenter); window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); return () => { element.removeEventListener("pointerdown", down); + element.removeEventListener("wheel", wheel); + element.removeEventListener("dblclick", recenter); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); }; - }, [element]); + }, [element, resetKey]); } type Hover = { title: string; detail: string; x: number; y: number } | null; @@ -125,19 +166,22 @@ function atlasCells(node: TrieNode, rect: Rect, depth: number, context: { } } -const CARD = { width: 184, height: 40, column: 236, padX: 16, padY: 32, gap: 8 }; +const CARD = { width: 226, height: 40, column: 278, padX: 16, padY: 14, gap: 8 }; +const NAME_CHARS = 24; +/** Characters the subtitle can take, once the boundary tag beside it has had its share. */ +const pathChars = (boundary: string) => Math.max(8, Math.floor((CARD.width - 30 - boundary.length * 5.4) / 5.05)); -function FlowView({ snapshot, start, max, height, allow, onRoot, onHover }: { - snapshot: AppIntelSnapshot; start: IntelRef; max: number; height: number; allow: ReadonlySet | null; +function FlowView({ snapshot, start, max, height, onRoot, onHover }: { + snapshot: AppIntelSnapshot; start: IntelRef; max: number; height: number; 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); + const built = buildFlow(snapshot, start); recordUiTiming("ui.app_intel.flow_layout", performance.now() - startedAt); return built; - }, [snapshot, start, allow]); + }, [snapshot, start]); 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); @@ -157,9 +201,6 @@ function FlowView({ snapshot, start, max, height, allow, onRoot, onHover }: { for (let cursor = lit; cursor; cursor = graph.parent.get(cursor) ?? null) path.add(cursor); return - {graph.levels.map((level, depth) => level.length > 0 && - {depth === 0 ? "ROOT" : `HOP ${depth}`} - )} {[...spots].map(([ref, spot]) => { const from = graph.parent.get(ref); const origin = from && spots.get(from); @@ -172,14 +213,14 @@ function FlowView({ snapshot, start, max, height, allow, onRoot, onHover }: { })} {overflow.map(spot => - +{spot.hidden} more at this hop + +{spot.hidden} more )} {[...spots].map(([ref, spot]) => { const reach = reachOf(snapshot, ref); const screen = ref.startsWith("screen:") ? snapshot.screens[ref.slice(7)] : null; const platforms = screen ? "both" as NodePlatforms : platformsFor(snapshot, ref); const boundary = boundaryOf(ref); - const detail = `${boundaryLabel[boundary]} · hop ${spot.depth}${screen ? ` · ${screen.route ?? ref.slice(7)}` : ` · reach ${reach}`}${platforms === "both" ? "" : ` · ${platformLabel(platforms)}`}`; + const detail = `${boundaryLabel[boundary]}${screen ? ` · ${screen.route ?? ref.slice(7)}` : ` · reach ${reach}`}${platforms === "both" ? "" : ` · ${platformLabel(platforms)}`}`; const barWidth = Math.max(3, 44 * heatT(reach, max)); const on = path.has(ref); return {platforms !== "both" && } - {labelOf(ref).slice(0, 20)} + {clampName(labelOf(ref), NAME_CHARS)} {boundary} - {(screen ? screen.route ?? "" : groupOf(ref)).slice(0, 24 - boundary.length)} + {ellipsize(screen ? screen.route ?? "" : pathOf(ref), pathChars(boundary))} {!screen && <> {reach} @@ -205,14 +246,14 @@ function FlowView({ snapshot, start, max, height, allow, onRoot, onHover }: { ; } -/** The Flow's boundary filter: one chip per boundary the traced graph actually contains. */ +/** The Flow's boundary filter: one chip per boundary the app's entry points fall into. */ 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; + if (!available.length) return null; const all = available.every(entry => selected.has(entry.boundary)); - return
+ return
@@ -236,7 +277,6 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: 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(); @@ -277,12 +317,16 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: 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]); + // An entry point is classified by the file it renders: the ids are routes, which say nothing. + const entries = useMemo(() => Object.entries(snapshot?.screens ?? {}) + .map(([id, screen]) => ({ id, screen, boundary: screen.source ? boundaryOf(fileRef(screen.source)) : "entry" as Boundary })), [snapshot]); + const available = useMemo(() => boundariesIn(entries.map(entry => entry.boundary)), [entries]); const selectedBoundaries = useMemo(() => new Set(state.boundaries ?? available.map(entry => entry.boundary)), [state.boundaries, available]); - // 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]); + // Re-centre the canvas whenever the graph it holds changes, so a new trace is never panned away. + usePanCanvas(flowBox, flowStart ?? ""); + const screens = useMemo(() => entries.filter(({ id, screen, boundary }) => selectedBoundaries.has(boundary) + && (!search || `${id} ${screen.title} ${screen.route ?? ""} ${screen.source ?? ""}`.toLowerCase().includes(search))), [entries, selectedBoundaries, search]); 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
- + void panel.selectProject(path)} onBrowse={() => void panel.pickProject()} /> {snapshot &&
{(["atlas", "flow"] as const).map(view => }
- {flowStart && panel.setBoundaries(null)} />} -
- {flowStart ? { if (ref.startsWith("file:")) panel.setFlowRoot(ref.slice(5)); }} onHover={setHover} /> - :
Choose a screen to trace its dependencies.
} + panel.setBoundaries(null)} /> +
+
+ {flowStart ? { if (ref.startsWith("file:")) panel.setFlowRoot(ref.slice(5)); }} onHover={setHover} /> + :
Choose an entry point 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.

+

Each column goes one import deeper, tinted by depth. Drag or scroll to pan, double-click to recentre, click a card to re-root, Esc returns.

}
} diff --git a/packages/ui/test/app-intelligence-layout.test.ts b/packages/ui/test/app-intelligence-layout.test.ts index bc7fe945..73318514 100644 --- a/packages/ui/test/app-intelligence-layout.test.ts +++ b/packages/ui/test/app-intelligence-layout.test.ts @@ -1,7 +1,7 @@ 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"; +import { boundariesIn, boundaryOf, buildFlow, buildTrie, clampName, depthHue, depthTint, ellipsize, findNode, heat, heatT, labelOf, leavesOf, maxReach, ownerScreen, pathOf, 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) }; @@ -141,7 +141,7 @@ test("ownerScreen walks back to a screen that reaches the file", () => { }); test("boundaryOf reads a role off the path", () => { - assert.equal(boundaryOf("screen:home"), "screen"); + assert.equal(boundaryOf("screen:home"), "entry", "an analyzer root is an entry point, not necessarily a screen"); assert.equal(boundaryOf("package:left-pad"), "package"); assert.equal(boundaryOf("file:src/state/usePostThread.ts"), "hook", "a use-prefixed file is a hook wherever it lives"); assert.equal(boundaryOf("file:src/hooks/session.ts"), "hook"); @@ -150,39 +150,22 @@ test("boundaryOf reads a role off the path", () => { 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/view/shell/index.tsx"), "entry"); assert.equal(boundaryOf("file:src/types/post.d.ts"), "model"); assert.equal(boundaryOf("file:ios/AppDelegate.swift"), "native"); assert.equal(boundaryOf("file:assets/icon.png"), "asset"); assert.equal(boundaryOf("file:src/lib/strings.ts"), "util"); + assert.equal(boundaryOf("file:src/lexicons/getProfile.ts"), "model", "generated schema definitions are not utilities"); + assert.equal(boundaryOf("file:src/weird/thing.ts"), "other", "an unrecognised path says so rather than claiming a role"); + assert.equal(boundaryOf("file:node_modules/@atproto/api/dist/index.js"), "package", "a dependency is a package however the analyzer recorded it"); + assert.equal(boundaryOf("file:node_modules/react-native/Libraries/Image/Image.ios.js"), "package", "a dependency's own folder names never classify it"); }); -test("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("boundariesIn counts in display order and lists only what it was given", () => { + assert.deepEqual(boundariesIn(["hook", "entry", "hook", "model"]), [ + { boundary: "entry", count: 1 }, { boundary: "hook", count: 2 }, { boundary: "model", count: 1 }, + ]); + assert.deepEqual(boundariesIn([]), []); }); test("depth tints differ per hop and settle at the last one", () => { @@ -190,3 +173,20 @@ test("depth tints differ per hop and settle at the last one", () => { assert.equal(depthHue(40), depthHue(4)); assert.match(depthTint(0, 0.2), /^rgba\(\d+,\d+,\d+,0\.2\)$/); }); + +test("a barrel file is named for the folder it indexes, and keeps its whole path", () => { + assert.equal(labelOf("file:src/components/forms/Toggle/index.tsx"), "Toggle"); + assert.equal(pathOf("file:src/components/forms/Toggle/index.tsx"), "src/components/forms"); + assert.equal(labelOf("file:src/components/Typography.tsx"), "Typography.tsx"); + assert.equal(pathOf("file:src/components/Typography.tsx"), "src/components"); + assert.equal(labelOf("file:index.ts"), "index.ts", "a barrel at the root has no folder to borrow"); + assert.equal(labelOf("package:react-native-screens"), "react-native-screens"); + assert.equal(pathOf("package:react-native-screens"), "node_modules/"); +}); + +test("a long path truncates from the left and a long name from the right", () => { + assert.equal(ellipsize("src/screens/Settings/components", 14), "…gs/components"); + assert.equal(ellipsize("src/alf", 14), "src/alf"); + assert.equal(clampName("ModerationInboxSettings.tsx", 12), "ModerationI…"); + assert.equal(clampName("Toggle", 12), "Toggle"); +}); diff --git a/packages/ui/test/app-intelligence-view.test.ts b/packages/ui/test/app-intelligence-view.test.ts index f62d79c5..f6640aa7 100644 --- a/packages/ui/test/app-intelligence-view.test.ts +++ b/packages/ui/test/app-intelligence-view.test.ts @@ -27,7 +27,7 @@ function fixture(): AppIntelSnapshot { 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 } }, + packages: { "left-pad": { name: "left-pad", reach: 2, entry: "node_modules/left-pad/index.js" } }, screens: { home: { title: "Home", route: "/", source: "app/index.tsx", layouts: [] }, profile: { title: "Profile", route: "/profile", source: "app/profile.tsx", layouts: [] }, @@ -79,8 +79,12 @@ test("App Intelligence renders the Atlas, hides packages by default and traces a 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 } }; + const calls: Array<{ name: string; arguments?: Record }> = []; + const recents = ["/tmp/social-app", "/tmp/other-app"]; + const app = { async callServerTool({ name, arguments: args }: { name: string; arguments?: Record }) { + calls.push({ name, arguments: args }); + if (name === "mobile_app_intel_snapshot") return { content: [], structuredContent: { projectPath: "/tmp/social-app", snapshot, recents } }; + if (name === "mobile_select_app_intel_project") return { content: [], structuredContent: { projectPath: args?.projectPath, snapshot, recents } }; return { content: [], structuredContent: {} }; } }; const panel = new AppIntelligencePanel(app as unknown as App); @@ -109,6 +113,7 @@ test("App Intelligence renders the Atlas, hides packages by default and traces a 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"); + assert.ok(query("[data-flow-filter]").length === 1, "the entry-point filter is there before anything is traced"); 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(); }); @@ -117,20 +122,57 @@ test("App Intelligence renders the Atlas, hides packages by default and traces a 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"); + assert.ok(!/\bHOP\b|\bROOT\b/.test(dom.window.document.querySelector("[data-flow-canvas]")?.textContent ?? ""), "the depth tint carries the hop, with no column headings"); + // 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. + // The filter is the entry-point rail's filter: its chips come from what the entry points are, + // and unticking one takes those entries out of the rail without touching the traced graph. const chips = () => [...query("[data-flow-filter] [data-boundary]")].map(chip => chip.getAttribute("data-boundary")); - assert.deepEqual(chips(), ["all", "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"); + const railEntries = () => [...query("aside [data-entry]")].map(entry => entry.getAttribute("data-entry")); + assert.deepEqual(chips(), ["all", "other"], "both fixture entries render plain source files"); + assert.deepEqual(railEntries().sort(), ["home", "profile"]); + const other = dom.window.document.querySelector('[data-flow-filter] [data-boundary="other"]') as HTMLButtonElement; + await act(async () => { other.click(); }); + assert.equal(other.getAttribute("aria-pressed"), "false"); + assert.deepEqual(railEntries(), [], "unticking the only boundary empties the rail"); + assert.match((dom.window.document.querySelector("aside") as HTMLElement).textContent ?? "", /0 entry points/, "the header counts what the rail shows"); + assert.ok([...query("[data-flow-node]")].length > 0, "the filter is the rail's, so the traced graph still draws in full"); await act(async () => { (dom.window.document.querySelector('[data-flow-filter] [data-boundary="all"]') as HTMLButtonElement).click(); }); - assert.ok([...query("[data-flow-node]")].some(node => node.getAttribute("data-flow-node") === "file:src/shared.ts"), "All brings it back"); + assert.deepEqual(railEntries().sort(), ["home", "profile"], "All brings them back"); + + // Dragging pans the board itself, which is what the old scroll-based pan failed to do when the + // graph already fitted its pane. The drag must not re-root the graph on the card underneath. + const canvas = dom.window.document.querySelector("[data-flow-canvas]") as HTMLElement; + const board = canvas.firstElementChild as HTMLElement; + const pointer = (type: string, x: number, y: number, target: EventTarget) => + target.dispatchEvent(new dom.window.MouseEvent(type, { clientX: x, clientY: y, bubbles: true, button: 0 })); + assert.equal(board.style.transform, "translate(0px, 0px)"); + const rooted = panel.getSnapshot().flowRoot; + await act(async () => { + pointer("pointerdown", 300, 200, canvas); + pointer("pointermove", 220, 170, dom.window); + pointer("pointerup", 220, 170, dom.window); + }); + assert.equal(board.style.transform, "translate(-80px, -30px)", "the board follows the pointer"); + await act(async () => { (dom.window.document.querySelector("[data-flow-node]") as Element).dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(panel.getSnapshot().flowRoot, rooted, "the click that ends a drag is swallowed"); + await act(async () => { canvas.dispatchEvent(new dom.window.MouseEvent("dblclick", { bubbles: true })); }); + assert.equal(board.style.transform, "translate(0px, 0px)", "double-click recentres"); + + // The picker is a dropdown over the remembered projects, so switching needs no folder picker. + const picker = dom.window.document.querySelector("[data-project-picker]") as HTMLSelectElement; + assert.equal(picker.tagName, "SELECT"); + assert.deepEqual([...picker.options].map(option => option.textContent), ["social-app", "other-app", "Choose another project…"]); + assert.equal(picker.value, "/tmp/social-app", "the current project is the one shown"); + await act(async () => { + picker.value = "/tmp/other-app"; + picker.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + assert.deepEqual(calls.at(-1), { name: "mobile_select_app_intel_project", arguments: { projectPath: "/tmp/other-app" } }); + assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app"); // 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(); }); From 013bb150e344bcbabd98277a37dbdf1df52027da Mon Sep 17 00:00:00 2001 From: Hur Ali Date: Thu, 5 Nov 2026 14:13:27 +0500 Subject: [PATCH 3/9] fix: make chart readable --- docs/telemetry.md | 24 +- packages/ui/src/app-intelligence-layout.ts | 396 +++++++++-------- packages/ui/src/app-intelligence-panel.ts | 50 ++- .../src/components/app-intelligence-view.tsx | 406 +++++++++--------- .../ui/test/app-intelligence-layout.test.ts | 217 ++++++---- .../ui/test/app-intelligence-view.test.ts | 172 +++++--- packages/ui/test/ui-telemetry.test.ts | 20 +- 7 files changed, 728 insertions(+), 557 deletions(-) diff --git a/docs/telemetry.md b/docs/telemetry.md index f21c232f..6393a4db 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -659,13 +659,21 @@ App Intelligence uses the `app-intel` surface. `app_intel.operation.duration` an `.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 -entry-point 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 +displayed snapshot. `ui.app_intel.flow_layout` times building and placing the +per-screen dependency graph — the hop layering, its boundary grouping and the +geometry for the chosen reading direction — and `ui.app_intel.flow_nodes` gauges +how many cards it drew, which is what drives that cost. The panel no longer has a +directory treemap, so the Atlas measurements `ui.app_intel.atlas_layout`, +`ui.app_intel.atlas_cells`, `ui.app_intel.view_switch` and `ui.app_intel.zoom` are +gone rather than renamed; `flow_layout` and `flow_nodes` keep their meaning and +units, so their history stays comparable even though they now cover grouping and +placement as well. `ui.app_intel.orientation_switch` counts switching between the +column and top-down readings, `ui.app_intel.group_collapse`, `.group_expand` and +`.group_show_all` count folding a boundary away, bringing it back and opening one +past its row cap, `ui.app_intel.boundary_highlight` counts lighting one boundary's +connectors up, `ui.app_intel.project_switch` counts picking a remembered +project instead of the host folder picker, and `ui.app_intel.boundary_filter` +counts how often the entry-point filter is changed. All of these count the change +only; which boundary, project or direction was picked is not an attribute. Project paths, source paths, screen and package names, source contents, queries, diagnostics, graph edges and snapshot identifiers remain local and are never telemetry attributes. diff --git a/packages/ui/src/app-intelligence-layout.ts b/packages/ui/src/app-intelligence-layout.ts index a327bf87..670c335d 100644 --- a/packages/ui/src/app-intelligence-layout.ts +++ b/packages/ui/src/app-intelligence-layout.ts @@ -1,15 +1,12 @@ 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: + * Pure geometry and aggregation for the App Intelligence Flow. Nothing here touches the DOM: * the panel measures once and passes width and height in, so every layout stays testable. */ export type NodePlatforms = "ios" | "android" | "both"; -export type 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/"; @@ -28,169 +25,12 @@ export function maxReach(snapshot: AppIntelSnapshot): number { 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): 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; - 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("/"); -} - const INDEX_FILE = /^index\.[cm]?[jt]sx?$/; /** @@ -205,10 +45,7 @@ export function labelOf(ref: IntelRef): string { return INDEX_FILE.test(name) && parts.length ? parts[parts.length - 1] : name; } -/** - * Where that node lives, in full. `groupOf` keeps two segments because it clusters a column; - * a label needs the whole path, since several `index.tsx` differ by nothing else. - */ +/** Where that node lives, in full: several `index.tsx` differ by nothing else. */ export function pathOf(ref: IntelRef): string { if (ref.startsWith("package:")) return PACKAGE_PREFIX; const parts = ref.replace(/^(?:file|screen):/, "").split("/"); @@ -231,7 +68,7 @@ const RAMP = ["#1d4a57", "#1f7d82", "#2fa37f", "#79b85a", "#d7c045", "#e09440", /** * 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. + * whole graph 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; @@ -246,12 +83,6 @@ export function heat(reach: number, max: number): string { 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] { @@ -259,8 +90,8 @@ function channels(value: string): [number, number, number] { } /** - * 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. + * Nesting tints. One muted card for every hop read as a grey ladder; one hue per depth makes the + * hop a node sits at legible at a glance, in either theme. */ const DEPTH_HUES = ["#6366f1", "#0ea5e9", "#10b981", "#f59e0b", "#ec4899"] as const; @@ -356,3 +187,220 @@ export function boundariesIn(values: Iterable): Array<{ boundary: Boun for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); return boundaries.filter(boundary => counts.has(boundary)).map(boundary => ({ boundary, count: counts.get(boundary) as number })); } + +// ── The Flow graph ──────────────────────────────────────────────────────────────────────────── + +/** Import hops drawn after the root. */ +export const FLOW_DEPTH = 3; +/** Rows a boundary shows before the rest fold into one "+N more" that opens them. */ +export const GROUP_CAP = 6; + +export type FlowGroup = { + key: string; boundary: Boundary; refs: IntelRef[]; count: number; hidden: number; collapsed: boolean; + /** The cards at the hop above that import into this boundary — what its connectors come from. */ + sources: IntelRef[]; +}; +export type FlowGraph = { start: IntelRef; levels: FlowGroup[][]; parent: Map; total: number }; +export type FlowOptions = { depth?: number; groupCap?: number; toggled?: ReadonlySet; expanded?: ReadonlySet }; + +/** One key per boundary per hop, because the same boundary recurs at every depth. */ +export const groupKey = (depth: number, boundary: Boundary) => `${depth}:${boundary}`; + +/** + * A whole app's imports three hops deep is thousands of cards and a thicket of links, so a hop + * opens only its first boundary in display order and names the rest. That leaves one readable + * spine down the graph to drill along, rather than a wall of cards or a row of bare headers. + * `toggled` holds the keys flipped away from this default, in either direction, so one set + * covers both folding an open boundary and opening a folded one. + */ +export const openByDefault = (depth: number, index: number) => depth <= 1 || index === 0; + +/** + * Breadth-first layering of one screen's dependencies, one level per import hop, with each level + * split into the boundaries its nodes fall into. A boundary is the unit the reader folds away: + * collapsing one drops its rows and everything they would have pulled in at the next hop. + */ +export function buildFlow(snapshot: AppIntelSnapshot, start: IntelRef, options: FlowOptions = {}): FlowGraph { + const limit = options.depth ?? FLOW_DEPTH; + const cap = options.groupCap ?? GROUP_CAP; + const toggledKeys = options.toggled ?? new Set(); + const expandedKeys = options.expanded ?? new Set(); + const parent = new Map(); + const visited = new Set([start]); + const rootBoundary = boundaryOf(start); + const levels: FlowGroup[][] = [[{ key: groupKey(0, rootBoundary), boundary: rootBoundary, refs: [start], count: 1, hidden: 0, collapsed: false, sources: [] }]]; + let frontier: IntelRef[] = [start]; + let total = 1; + for (let depth = 1; depth <= limit && frontier.length; depth++) { + const next: IntelRef[] = []; + for (const ref of frontier) for (const index of snapshot.indexes.outgoing[ref] ?? []) { + const edge = snapshot.edges[index]; + if (!edge.to || edge.kind === "navigation" || visited.has(edge.to)) continue; + visited.add(edge.to); + parent.set(edge.to, ref); + next.push(edge.to); + } + if (!next.length) break; + const byBoundary = new Map(); + const sourcesOf = new Map>(); + for (const ref of next) { + const boundary = boundaryOf(ref); + const bucket = byBoundary.get(boundary); + if (bucket) bucket.push(ref); else byBoundary.set(boundary, [ref]); + // Captured before the fold prunes `parent`, so a folded boundary still knows what feeds it. + const from = parent.get(ref); + if (from) (sourcesOf.get(boundary) ?? sourcesOf.set(boundary, new Set()).get(boundary) as Set).add(from); + } + const level: FlowGroup[] = []; + const carry: IntelRef[] = []; + for (const boundary of boundaries) { + const members = byBoundary.get(boundary); + if (!members) continue; + const index = level.length; + members.sort((a, b) => reachOf(snapshot, b) - reachOf(snapshot, a) || labelOf(a).localeCompare(labelOf(b))); + const key = groupKey(depth, boundary); + const open = toggledKeys.has(key) ? !openByDefault(depth, index) : openByDefault(depth, index); + const collapsed = !open; + const shown = collapsed ? [] : members.slice(0, expandedKeys.has(key) ? members.length : cap); + const kept = new Set(shown); + // A row that is not drawn has no card to trace from, so it seeds no edge and no next hop. + for (const ref of members) if (!kept.has(ref)) parent.delete(ref); + // A folded group reports its whole size on the header, so it needs no "+N more" as well. + level.push({ key, boundary, refs: shown, count: members.length, hidden: collapsed ? 0 : members.length - shown.length, collapsed, sources: [...(sourcesOf.get(boundary) ?? [])] }); + carry.push(...shown); + total += members.length; + } + levels.push(level); + frontier = carry; + } + return { start, levels, parent, total }; +} + +// ── Placing it ──────────────────────────────────────────────────────────────────────────────── + +export type FlowOrientation = "columns" | "tree"; +export type FlowMetrics = { + card: { width: number; height: number }; gap: number; header: number; headerGap: number; + groupGap: number; column: number; rowGap: number; pad: number; indent: number; more: number; perRow: number; +}; + +/** + * `column` and `rowGap` are deliberately wide: the links between hops fan out across the whole + * gap, and at the old 52px they crossed each other into a thicket before reaching their cards. + */ +export const FLOW_METRICS: FlowMetrics = { + card: { width: 226, height: 40 }, gap: 8, header: 22, headerGap: 6, + groupGap: 20, column: 376, rowGap: 92, pad: 16, indent: 10, more: 22, perRow: 4, +}; + +export type PlacedHeader = { key: string; boundary: Boundary; depth: number; count: number; shown: number; collapsed: boolean; sources: IntelRef[]; rect: Rect }; +export type PlacedCard = { ref: IntelRef; depth: number; rect: Rect }; +export type PlacedMore = { key: string; boundary: Boundary; depth: number; hidden: number; sources: IntelRef[]; rect: Rect }; +export type FlowPlacement = { + width: number; height: number; + headers: PlacedHeader[]; cards: PlacedCard[]; more: PlacedMore[]; + rectOf: Map; + /** Where each boundary header landed, so a folded group can still be drawn a connector. */ + headerRectOf: Map; +}; + +type Block = { headers: PlacedHeader[]; cards: PlacedCard[]; more: PlacedMore[]; width: number; height: number }; + +/** A level laid out at the origin: one stack of boundaries, to be shifted into its column. */ +function columnBlock(level: FlowGroup[], depth: number, metrics: FlowMetrics): Block { + const { card, gap, header, headerGap, groupGap, indent, more } = metrics; + const block: Block = { headers: [], cards: [], more: [], width: card.width, height: 0 }; + let y = 0; + for (const group of level) { + block.headers.push({ key: group.key, boundary: group.boundary, depth, count: group.count, shown: group.refs.length, collapsed: group.collapsed, sources: group.sources, rect: { x: 0, y, width: card.width, height: header } }); + y += header + headerGap; + for (const ref of group.refs) { + block.cards.push({ ref, depth, rect: { x: indent, y, width: card.width - indent, height: card.height } }); + y += card.height + gap; + } + if (group.hidden) { + block.more.push({ key: group.key, boundary: group.boundary, depth, hidden: group.hidden, sources: group.sources, rect: { x: indent, y, width: card.width - indent, height: more } }); + y += more + gap; + } + y += groupGap - (group.refs.length || group.hidden ? gap : 0); + } + block.height = Math.max(0, y - groupGap); + return block; +} + +/** The same level laid out as a row: boundaries side by side, their rows wrapping under them. */ +function treeBlock(level: FlowGroup[], depth: number, metrics: FlowMetrics): Block { + const { card, gap, header, headerGap, groupGap, more, perRow } = metrics; + const block: Block = { headers: [], cards: [], more: [], width: 0, height: 0 }; + let x = 0; + for (const group of level) { + const across = Math.min(perRow, Math.max(1, group.refs.length)); + const width = across * (card.width + gap) - gap; + block.headers.push({ key: group.key, boundary: group.boundary, depth, count: group.count, shown: group.refs.length, collapsed: group.collapsed, sources: group.sources, rect: { x, y: 0, width, height: header } }); + const top = header + headerGap; + group.refs.forEach((ref, index) => block.cards.push({ + ref, depth, + rect: { x: x + (index % perRow) * (card.width + gap), y: top + Math.floor(index / perRow) * (card.height + gap), width: card.width, height: card.height }, + })); + let bottom = top + Math.ceil(group.refs.length / perRow) * (card.height + gap); + if (group.hidden) { + block.more.push({ key: group.key, boundary: group.boundary, depth, hidden: group.hidden, sources: group.sources, rect: { x, y: bottom, width: card.width, height: more } }); + bottom += more + gap; + } + block.height = Math.max(block.height, bottom - gap); + x += width + groupGap; + } + block.width = Math.max(0, x - groupGap); + return block; +} + +function shift(block: Block, dx: number, dy: number) { + for (const item of [...block.headers, ...block.cards, ...block.more]) { item.rect.x += dx; item.rect.y += dy; } +} + +/** + * Places a built graph for either reading direction. "columns" is one boundary stack per hop, + * read left to right; "tree" stacks the hops downwards, each one a row of boundaries. Levels are + * centred on the cross axis so the root sits against the middle of what it pulls in. + */ +export function placeFlow(graph: FlowGraph, orientation: FlowOrientation, metrics: FlowMetrics = FLOW_METRICS): FlowPlacement { + const make = orientation === "columns" ? columnBlock : treeBlock; + const blocks = graph.levels.map((level, depth) => depth === 0 + ? { headers: [], cards: [{ ref: level[0].refs[0], depth: 0, rect: { x: 0, y: 0, width: metrics.card.width, height: metrics.card.height } }], more: [], width: metrics.card.width, height: metrics.card.height } satisfies Block + : make(level, depth, metrics)); + // Every level is anchored to the start of the cross axis rather than centred on it. Centring + // reads better on a still graph, but folding one boundary then moves every other level, which + // throws away where the reader had panned to. A fixed origin keeps folds local. + let width = 0, height = 0; + if (orientation === "columns") { + blocks.forEach((block, depth) => shift(block, metrics.pad + depth * metrics.column, metrics.pad)); + width = metrics.pad * 2 + (blocks.length - 1) * metrics.column + metrics.card.width; + height = metrics.pad * 2 + Math.max(0, ...blocks.map(block => block.height)); + } else { + let y = metrics.pad; + for (const block of blocks) { shift(block, metrics.pad, y); y += block.height + metrics.rowGap; } + width = metrics.pad * 2 + Math.max(0, ...blocks.map(block => block.width)); + height = Math.max(metrics.pad * 2, y - metrics.rowGap + metrics.pad); + } + const placement: FlowPlacement = { width, height, headers: [], cards: [], more: [], rectOf: new Map(), headerRectOf: new Map() }; + for (const block of blocks) { + placement.headers.push(...block.headers); + placement.cards.push(...block.cards); + placement.more.push(...block.more); + for (const card of block.cards) placement.rectOf.set(card.ref, card.rect); + for (const header of block.headers) placement.headerRectOf.set(header.key, header.rect); + } + return placement; +} + +/** The curve between two placed cards, bent along the axis the orientation reads in. */ +export function flowLink(from: Rect, to: Rect, orientation: FlowOrientation): string { + if (orientation === "columns") { + const x1 = from.x + from.width, y1 = from.y + from.height / 2, x2 = to.x, y2 = to.y + to.height / 2; + const bend = Math.max(8, (x2 - x1) * 0.45); + return `M${x1} ${y1} C${x1 + bend} ${y1} ${x2 - bend} ${y2} ${x2} ${y2}`; + } + const x1 = from.x + from.width / 2, y1 = from.y + from.height, x2 = to.x + to.width / 2, y2 = to.y; + const bend = Math.max(8, (y2 - y1) * 0.45); + return `M${x1} ${y1} C${x1} ${y1 + bend} ${x2} ${y2 - bend} ${x2} ${y2}`; +} diff --git a/packages/ui/src/app-intelligence-panel.ts b/packages/ui/src/app-intelligence-panel.ts index 41c46e60..ddbdab63 100644 --- a/packages/ui/src/app-intelligence-panel.ts +++ b/packages/ui/src/app-intelligence-panel.ts @@ -8,7 +8,18 @@ export type AppIntelligenceState = { error: string; selectedScreen: string | null; /** Projects the panel remembers, most recent first, so switching needs no folder picker. */ recents: string[]; - view: "atlas" | "flow"; atlasPath: string; showPackages: boolean; flowRoot: string | null; + /** Which way the Flow reads: one boundary stack per hop, or the hops stacked downwards. */ + orientation: "columns" | "tree"; + /** + * Boundary groups flipped away from their default fold (only the first hop opens by itself), + * and those opened past their row cap. Both are keyed ":". + */ + toggled: string[]; expanded: string[]; + /** + * The boundary whose connectors are drawn at full strength: ":" from a header, + * or "*:" from a filter chip, which lights that boundary at every hop. null dims all. + */ + litBoundary: string | null; /** Boundaries the Flow may draw. null means every boundary, which is what a fresh graph shows. */ boundaries: string[] | null; }; @@ -19,7 +30,7 @@ function resultError(result: Awaited>) export class AppIntelligencePanel { private listeners = new Set<() => void>(); - private state: AppIntelligenceState = { open: false, available: false, projectPath: "", snapshot: null, run: null, busy: null, error: "", selectedScreen: null, recents: [], view: "atlas", atlasPath: "", showPackages: false, flowRoot: null, boundaries: null }; + private state: AppIntelligenceState = { open: false, available: false, projectPath: "", snapshot: null, run: null, busy: null, error: "", selectedScreen: null, recents: [], orientation: "columns", toggled: [], expanded: [], litBoundary: null, boundaries: null }; private app: HostBridge; constructor(app: HostBridge) { this.app = app; } subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); }; @@ -29,11 +40,28 @@ export class AppIntelligencePanel { 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" }); } - 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 }); } + // A new root means new hops, so the folds from the old one would land on unrelated boundaries. + selectScreen(selectedScreen: string | null) { this.update({ selectedScreen, toggled: [], expanded: [], litBoundary: null }); } + setOrientation(orientation: AppIntelligenceState["orientation"]) { if (orientation !== this.state.orientation) { countUiEvent("ui.app_intel.orientation_switch"); this.update({ orientation }); } } + /** `open` is what the click is asking for; the state stores only the flip from the default. */ + toggleGroup(key: string, open: boolean) { + countUiEvent(open ? "ui.app_intel.group_expand" : "ui.app_intel.group_collapse"); + const flipped = this.state.toggled.includes(key); + this.update({ toggled: flipped ? this.state.toggled.filter(entry => entry !== key) : [...this.state.toggled, key] }); + } + /** Clicking the lit boundary again puts every connector back to the same dim weight. */ + litBoundaryTo(key: string | null) { + const next = key === this.state.litBoundary ? null : key; + if (next) countUiEvent("ui.app_intel.boundary_highlight"); + this.update({ litBoundary: next }); + } + + /** Opens one boundary past its row cap. Folding it away later also forgets that it was opened. */ + showAllIn(key: string) { + if (this.state.expanded.includes(key)) return; + countUiEvent("ui.app_intel.group_show_all"); + this.update({ expanded: [...this.state.expanded, key] }); + } setBoundaries(boundaries: string[] | null) { countUiEvent("ui.app_intel.boundary_filter"); this.update({ boundaries }); } toggleBoundary(boundary: string, available: readonly string[]) { const current = this.state.boundaries ?? available; @@ -52,9 +80,7 @@ export class AppIntelligencePanel { 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, recents: value.recents ?? this.state.recents, busy: null, selectedScreen: screen, flowRoot, atlasPath }); + this.update({ projectPath: value.projectPath, snapshot: value.snapshot, recents: value.recents ?? this.state.recents, busy: null, selectedScreen: screen }); } 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 }); @@ -69,7 +95,7 @@ export class AppIntelligencePanel { 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 }); + this.update({ projectPath: value.projectPath ?? "", busy: null, snapshot: null, selectedScreen: null, toggled: [], expanded: [], litBoundary: null, boundaries: null }); await this.load(value.projectPath); } catch (error) { this.update({ busy: null, error: error instanceof Error ? error.message : String(error) }); captureUiError(error, "app_intel.pick"); } } @@ -77,7 +103,7 @@ export class AppIntelligencePanel { /** Switches to a remembered project without reopening the host folder picker. */ async selectProject(projectPath: string) { if (!this.state.available || projectPath === this.state.projectPath) return; - this.update({ busy: "picking", error: "", snapshot: null, selectedScreen: null, flowRoot: null, atlasPath: "", view: "atlas", boundaries: null }); + this.update({ busy: "picking", error: "", snapshot: null, selectedScreen: null, toggled: [], expanded: [], litBoundary: null, boundaries: null }); try { const result = await this.app.callServerTool({ name: "mobile_select_app_intel_project", arguments: { projectPath } }); if (result.isError) throw new Error(resultError(result)); diff --git a/packages/ui/src/components/app-intelligence-view.tsx b/packages/ui/src/components/app-intelligence-view.tsx index 91535696..04301ab4 100644 --- a/packages/ui/src/components/app-intelligence-view.tsx +++ b/packages/ui/src/components/app-intelligence-view.tsx @@ -1,11 +1,10 @@ -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 { ColumnsIcon, FolderOpenIcon, ListFilterIcon, MaximizeIcon, MinusIcon, NetworkIcon, PlusIcon, RefreshCwIcon, RowsIcon, SearchIcon, SquareIcon } from "lucide-react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { AppIntelligencePanel } from "../app-intelligence-panel.ts"; import type { AppIntelSnapshot, IntelRef } from "@mobile-dev/shared/app-intelligence.ts"; import { fileRef, screenRef } from "@mobile-dev/shared/app-intelligence.ts"; -import type { Boundary, NodePlatforms, Rect, TrieNode } from "../app-intelligence-layout.ts"; -import { boundariesIn, boundaryColor, boundaryLabel, boundaryOf, buildFlow, buildTrie, clampName, depthHue, depthTint, ellipsize, findNode, heat, heatInk, heatRamp, heatT, labelOf, leavesOf, ancestorsOf, maxReach, ownerScreen, pathOf, platformsFor, reachOf, squarify } from "../app-intelligence-layout.ts"; +import type { Boundary, FlowOrientation, NodePlatforms } from "../app-intelligence-layout.ts"; +import { boundariesIn, boundaryColor, boundaryLabel, boundaryOf, buildFlow, clampName, depthHue, depthTint, ellipsize, flowLink, heat, heatRamp, heatT, labelOf, maxReach, pathOf, placeFlow, platformsFor, reachOf, FLOW_METRICS } from "../app-intelligence-layout.ts"; import { recordUiTiming, setUiGauge } from "../telemetry.ts"; import { Alert, AlertDescription } from "./ui/alert.tsx"; import { Button } from "./ui/button.tsx"; @@ -57,25 +56,43 @@ function useMeasured() { return [setElement, size, element] as const; } +const ZOOM = { min: 0.3, max: 2.5, step: 1.25 } as const; +const clampZoom = (value: number) => Math.min(ZOOM.max, Math.max(ZOOM.min, value)); + /** - * Drag-to-pan and wheel-pan for the Flow. The board usually fits its pane, so moving scrollLeft - * did nothing; this translates the canvas instead, which pans whatever its size. A drag that - * actually moves swallows the click that follows it, so panning never re-roots the graph. + * Pan and zoom for the Flow. The board usually fits its pane, so moving scrollLeft did nothing; + * this transforms the canvas instead, which works whatever its size. A drag that actually moves + * swallows the click that follows it, so panning never re-roots the graph. + * + * The view state lives in a ref and is written straight to the style, so folding a boundary — a + * React re-render — never moves the board. Only an explicit reset or a new root recentres it. */ -function usePanCanvas(element: HTMLElement | null, resetKey: string) { +function usePanZoom(element: HTMLElement | null, resetKey: string) { + const view = useRef({ x: 0, y: 0, scale: 1 }); + const [zoom, setZoom] = useState(1); + const apply = useRef<(next: Partial<{ x: number; y: number; scale: number }>) => void>(() => {}); useEffect(() => { if (!element) return; const canvas = element.firstElementChild as HTMLElement | null; if (!canvas) return; - let x = 0, y = 0; - const draw = () => { canvas.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`; }; + canvas.style.transformOrigin = "0 0"; + const draw = () => { + const { x, y, scale } = view.current; + canvas.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px) scale(${scale})`; + }; + apply.current = next => { + if (next.scale !== undefined) next.scale = clampZoom(next.scale); + view.current = { ...view.current, ...next }; + setZoom(view.current.scale); + draw(); + }; draw(); let origin: { x: number; y: number; panX: number; panY: number } | null = null; let moved = false; const swallow = (event: Event) => { event.preventDefault(); event.stopPropagation(); }; const down = (event: PointerEvent) => { if (event.button !== 0) return; - origin = { x: event.clientX, y: event.clientY, panX: x, panY: y }; + origin = { x: event.clientX, y: event.clientY, panX: view.current.x, panY: view.current.y }; moved = false; }; const move = (event: PointerEvent) => { @@ -84,8 +101,8 @@ function usePanCanvas(element: HTMLElement | null, resetKey: string) { if (!moved && Math.abs(dx) < 4 && Math.abs(dy) < 4) return; moved = true; element.dataset.panning = "true"; - x = origin.panX + dx; - y = origin.panY + dy; + view.current.x = origin.panX + dx; + view.current.y = origin.panY + dy; draw(); }; const up = () => { @@ -96,11 +113,21 @@ function usePanCanvas(element: HTMLElement | null, resetKey: string) { }; const wheel = (event: WheelEvent) => { event.preventDefault(); - x -= event.deltaX; - y -= event.deltaY; + // Zoom about the pointer, so the card under the cursor stays under it. + if (event.ctrlKey || event.metaKey) { + const box = element.getBoundingClientRect(); + const pointerX = event.clientX - box.left, pointerY = event.clientY - box.top; + const { x, y, scale } = view.current; + const next = clampZoom(scale * Math.exp(-event.deltaY / 240)); + const ratio = next / scale; + apply.current({ scale: next, x: pointerX - (pointerX - x) * ratio, y: pointerY - (pointerY - y) * ratio }); + return; + } + view.current.x -= event.deltaX; + view.current.y -= event.deltaY; draw(); }; - const recenter = () => { x = 0; y = 0; draw(); }; + const recenter = () => apply.current({ x: 0, y: 0, scale: 1 }); element.addEventListener("pointerdown", down); element.addEventListener("wheel", wheel, { passive: false }); element.addEventListener("dblclick", recenter); @@ -115,107 +142,106 @@ function usePanCanvas(element: HTMLElement | null, resetKey: string) { window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); }; - }, [element, resetKey]); + }, [element]); + // A new root or reading direction is a new graph, so it starts from the default view. + useEffect(() => { apply.current({ x: 0, y: 0, scale: 1 }); }, [resetKey]); + const by = useCallback((factor: number) => { + const { scale, x, y } = view.current; + const next = clampZoom(scale * factor); + const box = element?.getBoundingClientRect(); + const centreX = (box?.width ?? 0) / 2, centreY = (box?.height ?? 0) / 2; + const ratio = next / scale; + apply.current({ scale: next, x: centreX - (centreX - x) * ratio, y: centreY - (centreY - y) * ratio }); + }, [element]); + const reset = useCallback(() => apply.current({ x: 0, y: 0, scale: 1 }), []); + return { zoom, zoomIn: useCallback(() => by(ZOOM.step), [by]), zoomOut: useCallback(() => by(1 / ZOOM.step), [by]), reset }; } type Hover = { title: string; detail: string; x: number; y: number } | null; -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: 226, height: 40, column: 278, padX: 16, padY: 14, gap: 8 }; const NAME_CHARS = 24; /** Characters the subtitle can take, once the boundary tag beside it has had its share. */ -const pathChars = (boundary: string) => Math.max(8, Math.floor((CARD.width - 30 - boundary.length * 5.4) / 5.05)); +const pathChars = (boundary: string) => Math.max(8, Math.floor((FLOW_METRICS.card.width - 30 - boundary.length * 5.4) / 5.05)); + +/** The three weights a connector is drawn at: dim by default, warmer on hover, full when lit. */ +const LINK = { dim: 0.2, hovered: 0.6, lit: 1 } as const; -function FlowView({ snapshot, start, max, height, onRoot, onHover }: { - snapshot: AppIntelSnapshot; start: IntelRef; max: number; height: number; - onRoot: (ref: IntelRef) => void; onHover: (hover: Hover) => void; +function FlowView({ snapshot, start, max, orientation, toggled, expanded, litBoundary, height, onToggleGroup, onLight, onShowAll, onHover }: { + snapshot: AppIntelSnapshot; start: IntelRef; max: number; orientation: FlowOrientation; + toggled: ReadonlySet; expanded: ReadonlySet; litBoundary: string | null; height: number; + onToggleGroup: (key: string, open: boolean) => void; onLight: (key: string) => void; + onShowAll: (key: string) => void; onHover: (hover: Hover) => void; }) { const [lit, setLit] = useState(null); - const graph = useMemo(() => { + const { graph, placement } = useMemo(() => { const startedAt = performance.now(); - const built = buildFlow(snapshot, start); + const built = buildFlow(snapshot, start, { toggled, expanded }); + const placed = placeFlow(built, orientation); recordUiTiming("ui.app_intel.flow_layout", performance.now() - startedAt); - return built; - }, [snapshot, start]); - 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]); + return { graph: built, placement: placed }; + }, [snapshot, start, orientation, toggled, expanded]); + useEffect(() => { setUiGauge("ui.app_intel.flow_nodes", placement.cards.length); }, [placement]); - 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); + // A chip lights its boundary at every hop; a header lights only its own. + const isLit = (boundary: Boundary, depth: number) => litBoundary === `${depth}:${boundary}` || litBoundary === `*:${boundary}`; + const weight = (boundary: Boundary, depth: number, hovered: boolean) => + isLit(boundary, depth) ? LINK.lit : hovered ? LINK.hovered : LINK.dim; - return - {[...spots].map(([ref, spot]) => { + return + {placement.cards.map(({ ref, depth, rect }) => { const from = graph.parent.get(ref); - const origin = from && spots.get(from); + const origin = from && placement.rectOf.get(from); if (!origin) return null; - const x1 = origin.x + CARD.width, y1 = origin.y + CARD.height / 2, x2 = spot.x, y2 = spot.y + CARD.height / 2; - const bend = Math.max(8, (x2 - x1) * 0.45); - const on = path.has(ref); - return ; + const opacity = weight(boundaryOf(ref), depth, path.has(ref)); + return ; + })} + {/* A folded boundary keeps its connectors: they land on the header instead of on its rows, + so the shape of what is hidden stays on screen rather than vanishing with the cards. */} + {[...placement.headers.filter(header => header.collapsed).map(header => ({ ...header, target: header.rect, kind: "group" })), + ...placement.more.map(entry => ({ ...entry, target: entry.rect, kind: "more" }))] + .flatMap(entry => entry.sources.map(source => { + const origin = placement.rectOf.get(source); + if (!origin) return null; + const opacity = weight(entry.boundary, entry.depth, false); + return ; + }))} + {placement.headers.map(header => { + const colour = boundaryColor(header.boundary); + const summary = header.collapsed ? `${header.count} hidden` : `${header.shown} of ${header.count}`; + const on = isLit(header.boundary, header.depth); + const pin = { x: header.rect.x + header.rect.width - 13, y: header.rect.y + header.rect.height / 2 }; + return + onToggleGroup(header.key, header.collapsed)} + onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onToggleGroup(header.key, header.collapsed); } }}> + + + {boundaryLabel[header.boundary]} + {header.collapsed ? header.count : `${header.shown}/${header.count}`} + + {/* The pin only lights this boundary's connectors, so folding stays the header's own job. */} + onLight(header.key)} + onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onLight(header.key); } }} /> + ; })} - {overflow.map(spot => - - +{spot.hidden} more + {placement.more.map(entry => onShowAll(entry.key)} + onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onShowAll(entry.key); } }}> + + +{entry.hidden} more — show all )} - {[...spots].map(([ref, spot]) => { + {placement.cards.map(({ ref, depth, rect }) => { const reach = reachOf(snapshot, ref); const screen = ref.startsWith("screen:") ? snapshot.screens[ref.slice(7)] : null; const platforms = screen ? "both" as NodePlatforms : platformsFor(snapshot, ref); @@ -223,23 +249,20 @@ function FlowView({ snapshot, start, max, height, onRoot, onHover }: { const detail = `${boundaryLabel[boundary]}${screen ? ` · ${screen.route ?? ref.slice(7)}` : ` · reach ${reach}`}${platforms === "both" ? "" : ` · ${platformLabel(platforms)}`}`; const barWidth = Math.max(3, 44 * heatT(reach, max)); const on = path.has(ref); - return { setLit(ref); onHover({ title: ref.replace(/^(?:file|package|screen):/, ""), detail, x: event.clientX, y: event.clientY }); }} onPointerLeave={() => { setLit(null); onHover(null); }} - onClick={() => onRoot(ref)} - onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onRoot(ref); } }} > - - - {platforms !== "both" && } - {clampName(labelOf(ref), NAME_CHARS)} - {boundary} - {ellipsize(screen ? screen.route ?? "" : pathOf(ref), pathChars(boundary))} + + + {platforms !== "both" && } + {clampName(labelOf(ref), NAME_CHARS)} + {boundary} + {ellipsize(screen ? screen.route ?? "" : pathOf(ref), pathChars(boundary))} {!screen && <> - {reach} - + {reach} + } ; })} @@ -247,9 +270,9 @@ function FlowView({ snapshot, start, max, height, onRoot, onHover }: { } /** The Flow's boundary filter: one chip per boundary the app's entry points fall into. */ -function BoundaryFilter({ available, selected, onToggle, onAll }: { - available: Array<{ boundary: Boundary; count: number }>; selected: ReadonlySet; - onToggle: (boundary: Boundary) => void; onAll: () => void; +function BoundaryFilter({ available, selected, lit, onToggle, onLight, onAll }: { + available: Array<{ boundary: Boundary; count: number }>; selected: ReadonlySet; lit: string | null; + onToggle: (boundary: Boundary) => void; onLight: (boundary: Boundary) => void; onAll: () => void; }) { if (!available.length) return null; const all = available.every(entry => selected.has(entry.boundary)); @@ -259,64 +282,41 @@ function BoundaryFilter({ available, selected, onToggle, onAll }: { className={`rounded-full border px-2 py-0.5 text-[10.5px] ${all ? "border-primary text-primary" : "text-muted-foreground"}`}>All {available.map(({ boundary, count }) => { const on = selected.has(boundary); - return - {boundaryLabel[boundary]} - {count} - ; + const highlighted = lit === `*:${boundary}`; + return + {/* The dot lights this boundary's connectors at every hop; the label still filters. */} + + + ; })} ; } +const ORIENTATIONS = [["columns", "Columns", ColumnsIcon], ["tree", "Top-down", RowsIcon]] as const; + 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(); 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"); - } - + // The rail is what roots the graph. A card is a place in that screen's tree, not a tree of its + // own: re-rooting on a click meant any stray click threw away where the reader had got to. const selected = state.selectedScreen && snapshot?.screens[state.selectedScreen] ? state.selectedScreen : null; - const flowStart: IntelRef | null = state.flowRoot ? fileRef(state.flowRoot) : selected ? screenRef(selected) : null; + const flowStart: IntelRef | null = selected ? screenRef(selected) : null; + const toggled = useMemo(() => new Set(state.toggled), [state.toggled]); + const expanded = useMemo(() => new Set(state.expanded), [state.expanded]); + // An entry point is classified by the file it renders: the ids are routes, which say nothing. const entries = useMemo(() => Object.entries(snapshot?.screens ?? {}) .map(([id, screen]) => ({ id, screen, boundary: screen.source ? boundaryOf(fileRef(screen.source)) : "entry" as Boundary })), [snapshot]); @@ -324,80 +324,58 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: const selectedBoundaries = useMemo(() => new Set(state.boundaries ?? available.map(entry => entry.boundary)), [state.boundaries, available]); const toggleBoundary = useCallback((boundary: Boundary) => panel.toggleBoundary(boundary, available.map(entry => entry.boundary)), [panel, available]); // Re-centre the canvas whenever the graph it holds changes, so a new trace is never panned away. - usePanCanvas(flowBox, flowStart ?? ""); + const { zoom, zoomIn, zoomOut, reset } = usePanZoom(flowBox, `${flowStart ?? ""}:${state.orientation}`); const screens = useMemo(() => entries.filter(({ id, screen, boundary }) => selectedBoundaries.has(boundary) && (!search || `${id} ${screen.title} ${screen.route ?? ""} ${screen.source ?? ""}`.toLowerCase().includes(search))), [entries, selectedBoundaries, search]); - 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); - }}> + return
void panel.selectProject(path)} onBrowse={() => void panel.pickProject()} /> - {snapshot &&
- {(["atlas", "flow"] as const).map(view => )} + {snapshot &&
+ {ORIENTATIONS.map(([value, label, Icon]) => )}
} - setQuery(event.target.value)} /> + setQuery(event.target.value)} /> {state.busy === "generating" || state.busy === "cancelling" ? : }
{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.

+ : !snapshot ?

No App Intelligence yet

Generate a static iOS + Android screen and dependency graph.

:
- {state.view === "atlas" ? <> -
- - -
reach{max}
+
+ {snapshot.screens[selected ?? ""]?.title ?? "Choose a screen"} +
reach{max}
+
+ + {Math.round(zoom * 100)}% + +
-
{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 ${labelOf(fileRef(state.flowRoot))}` : snapshot.screens[selected ?? ""]?.title ?? "Choose a screen"} - {state.flowRoot && } -
- panel.setBoundaries(null)} /> -
-
- {flowStart ? { if (ref.startsWith("file:")) panel.setFlowRoot(ref.slice(5)); }} onHover={setHover} /> - :
Choose an entry point to trace its dependencies.
} -
+
+ panel.litBoundaryTo(`*:${boundary}`)} onAll={() => panel.setBoundaries(null)} /> +
+
+ {flowStart ? panel.toggleGroup(key, open)} onLight={key => panel.litBoundaryTo(key)} onShowAll={key => panel.showAllIn(key)} onHover={setHover} /> + :
Choose an entry point to trace its dependencies.
}
-

Each column goes one import deeper, tinted by depth. Drag or scroll to pan, double-click to recentre, click a card to re-root, Esc returns.

- } +
+

Each hop groups its imports by boundary and opens the first of them — click another to drill in, or its dot to light up its connectors. Drag or scroll to pan, ⌘/Ctrl-scroll or the buttons to zoom, double-click to reset.

} diff --git a/packages/ui/test/app-intelligence-layout.test.ts b/packages/ui/test/app-intelligence-layout.test.ts index 73318514..87df4238 100644 --- a/packages/ui/test/app-intelligence-layout.test.ts +++ b/packages/ui/test/app-intelligence-layout.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { AppIntelSnapshot, IntelEdge } from "@mobile-dev/shared/app-intelligence.ts"; -import { boundariesIn, boundaryOf, buildFlow, buildTrie, clampName, depthHue, depthTint, ellipsize, findNode, heat, heatT, labelOf, leavesOf, maxReach, ownerScreen, pathOf, platformsFor, squarify } from "../src/app-intelligence-layout.ts"; +import { FLOW_METRICS, boundariesIn, boundaryOf, buildFlow, clampName, depthHue, depthTint, ellipsize, flowLink, groupKey, heat, heatT, labelOf, maxReach, openByDefault, pathOf, placeFlow, platformsFor } from "../src/app-intelligence-layout.ts"; function snapshotOf(edges: IntelEdge[], files: Record, packages: Record = {}): AppIntelSnapshot { const indexes: AppIntelSnapshot["indexes"] = { screens: Object.create(null), incoming: Object.create(null), outgoing: Object.create(null) }; @@ -10,7 +10,7 @@ function snapshotOf(edges: IntelEdge[], files: Record, packages: 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 }])), + packages: Object.fromEntries(Object.entries(packages).map(([name, reach]) => [name, { name, reach, entry: null }])), screens: { home: { title: "Home", route: "/", source: "src/screens/home.tsx", layouts: [] } }, edges, diagnostics: [], indexes, }; @@ -18,51 +18,6 @@ function snapshotOf(edges: IntelEdge[], files: Record, packages: 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"]), @@ -73,28 +28,8 @@ test("a file reached only on one platform reports that platform", () => { 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"); -}); +const refsIn = (level: ReturnType["levels"][number]) => level.flatMap(group => group.refs); +const groupsIn = (level: ReturnType["levels"][number]) => level.map(group => group.boundary); test("buildFlow terminates on an import cycle", () => { const snapshot = snapshotOf([ @@ -102,8 +37,137 @@ test("buildFlow terminates on an import cycle", () => { 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"); + assert.deepEqual(refsIn(flow.levels[1]), ["file:b.ts"]); + assert.equal(flow.levels[2], undefined, "a.ts is already visited, so the walk stops"); +}); + +/** One screen reaching a component, a hook and two packages: enough to group, fold and overflow. */ +function groupedSnapshot() { + return snapshotOf([ + { from: "screen:home", to: "file:src/screens/home.tsx", kind: "source", platforms: ["ios", "android"] }, + importEdge("file:src/screens/home.tsx", "file:src/components/Button.tsx"), + importEdge("file:src/screens/home.tsx", "file:src/components/Card.tsx"), + importEdge("file:src/screens/home.tsx", "file:src/hooks/useThing.ts"), + importEdge("file:src/screens/home.tsx", "package:react"), + importEdge("file:src/screens/home.tsx", "package:react-native"), + importEdge("file:src/components/Button.tsx", "file:src/util/text.ts"), + ], { "src/screens/home.tsx": 1, "src/components/Button.tsx": 1, "src/components/Card.tsx": 1, "src/hooks/useThing.ts": 1, "src/util/text.ts": 1 }, { react: 2, "react-native": 2 }); +} + +/** + * The keys to toggle so every boundary down to `depth` is open. Opening one hop reveals the next, + * so this repeats until the graph stops growing rather than guessing at the key names. + */ +function openTo(depth: number) { + const toggled = new Set(); + for (let round = 0; round <= depth; round++) { + for (const level of buildFlow(groupedSnapshot(), "screen:home", { toggled }).levels) { + for (const group of level) if (group.collapsed && Number(group.key.split(":")[0]) <= depth) toggled.add(group.key); + } + } + return toggled; +} + +test("a hop opens its first boundary and names the rest", () => { + assert.equal(openByDefault(1, 3), true, "the screen's own source is always open"); + assert.equal(openByDefault(2, 0), true, "and so is the first boundary of every hop below it"); + assert.equal(openByDefault(2, 1), false); + const flow = buildFlow(groupedSnapshot(), "screen:home"); + assert.deepEqual(refsIn(flow.levels[1]), ["file:src/screens/home.tsx"]); + assert.deepEqual(flow.levels[2].map(group => [group.boundary, group.collapsed, group.count]), [["hook", false, 1], ["component", true, 2], ["package", true, 2]]); + assert.deepEqual(refsIn(flow.levels[2]), ["file:src/hooks/useThing.ts"], "one readable spine, not a wall of cards"); + assert.equal(flow.levels[3], undefined, "the boundary left open here imports nothing further"); +}); + +test("buildFlow splits each hop into boundaries, in display order", () => { + const flow = buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(2) }); + assert.deepEqual(refsIn(flow.levels[1]), ["file:src/screens/home.tsx"]); + assert.deepEqual(groupsIn(flow.levels[2]), ["hook", "component", "package"], "boundaries keep the display order, not discovery order"); + assert.deepEqual(flow.levels[2].find(group => group.boundary === "package")?.refs, ["package:react", "package:react-native"]); + assert.deepEqual(refsIn(buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) }).levels[3]), ["file:src/util/text.ts"]); + assert.equal(flow.levels[2][0].key, groupKey(2, "hook")); +}); + +test("folding a boundary drops its rows and everything they would have pulled in", () => { + // openTo flips the folded boundaries open; dropping one key leaves that boundary at its default. + const open = openTo(3); + open.delete(groupKey(2, "component")); + const flow = buildFlow(groupedSnapshot(), "screen:home", { toggled: open }); + const components = flow.levels[2].find(group => group.boundary === "component"); + assert.deepEqual(components?.refs, []); + assert.equal(components?.collapsed, true); + assert.equal(components?.count, 2, "the header still reports what it is hiding"); + assert.equal(flow.levels[3], undefined, "Button.tsx was the only path to the next hop"); + assert.deepEqual(refsIn(flow.levels[2]), ["file:src/hooks/useThing.ts", "package:react", "package:react-native"], "the other boundaries are untouched"); +}); + +test("a boundary past its cap folds the rest into an overflow its key can open", () => { + const snapshot = groupedSnapshot(); + const capped = buildFlow(snapshot, "screen:home", { groupCap: 1, toggled: openTo(3) }); + const components = capped.levels[2].find(group => group.boundary === "component"); + assert.deepEqual(components?.refs, ["file:src/components/Button.tsx"]); + assert.equal(components?.hidden, 1); + assert.deepEqual(refsIn(capped.levels[3]), ["file:src/util/text.ts"], "the row that is drawn still seeds the next hop"); + const opened = buildFlow(snapshot, "screen:home", { groupCap: 1, toggled: openTo(3), expanded: new Set([groupKey(2, "component")]) }); + const all = opened.levels[2].find(group => group.boundary === "component"); + assert.deepEqual(all?.refs, ["file:src/components/Button.tsx", "file:src/components/Card.tsx"]); + assert.equal(all?.hidden, 0); +}); + +test("placeFlow lays a hop out as a column or as a row, without overlapping its cards", () => { + const flow = buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) }); + for (const orientation of ["columns", "tree"] as const) { + const placed = placeFlow(flow, orientation); + assert.equal(placed.cards.length, 8, `${orientation} places every drawn row`); + assert.equal(placed.headers.length, 5, `${orientation} draws one header per boundary, and none for the root`); + assert.ok(placed.cards.every(card => placed.rectOf.get(card.ref) === card.rect)); + for (let a = 0; a < placed.cards.length; a++) for (let b = a + 1; b < placed.cards.length; b++) { + const first = placed.cards[a].rect, second = placed.cards[b].rect; + const overlaps = first.x < second.x + second.width - 0.001 && second.x < first.x + first.width - 0.001 + && first.y < second.y + second.height - 0.001 && second.y < first.y + first.height - 0.001; + assert.ok(!overlaps, `${orientation}: ${placed.cards[a].ref} overlaps ${placed.cards[b].ref}`); + } + for (const card of placed.cards) { + assert.ok(card.rect.x >= 0 && card.rect.y >= 0 && card.rect.x + card.rect.width <= placed.width + 0.001 && card.rect.y + card.rect.height <= placed.height + 0.001, `${orientation} keeps every card inside the board`); + } + } +}); + +test("columns advance along x and the tree advances down y", () => { + const flow = buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) }); + const depthOf = (placed: ReturnType, depth: number) => placed.cards.filter(card => card.depth === depth); + const columns = placeFlow(flow, "columns"); + // Rows under a header are indented past it, which the root card has no header to sit under. + assert.equal(columns.headers[0].rect.x - depthOf(columns, 0)[0].rect.x, FLOW_METRICS.column); + assert.equal(depthOf(columns, 1)[0].rect.x - depthOf(columns, 0)[0].rect.x, FLOW_METRICS.column + FLOW_METRICS.indent); + assert.equal(depthOf(columns, 2).every(card => card.rect.x === depthOf(columns, 1)[0].rect.x + FLOW_METRICS.column), true); + // Anchored to the start, not centred: folding one boundary must not move any other level. + assert.equal(depthOf(columns, 0)[0].rect.y, FLOW_METRICS.pad); + const folded = placeFlow(buildFlow(groupedSnapshot(), "screen:home"), "columns"); + assert.deepEqual(depthOf(folded, 0)[0].rect, depthOf(columns, 0)[0].rect, "the root sits where it sat before the fold"); + assert.deepEqual(depthOf(folded, 1)[0].rect, depthOf(columns, 1)[0].rect); + const tree = placeFlow(flow, "tree"); + assert.ok(depthOf(tree, 2)[0].rect.y > depthOf(tree, 1)[0].rect.y, "each hop sits below the one before it"); + assert.equal(new Set(depthOf(tree, 2).slice(0, 2).map(card => card.rect.y)).size, 1, "rows in one hop share a baseline"); + assert.ok(tree.height > columns.height, "the tree is the taller reading of the same graph"); +}); + +test("a folded boundary still records what feeds it, and where its header landed", () => { + const folded = buildFlow(groupedSnapshot(), "screen:home"); + const components = folded.levels[2].find(group => group.boundary === "component"); + assert.deepEqual(components?.sources, ["file:src/screens/home.tsx"], "the connector has somewhere to come from"); + const placed = placeFlow(folded, "columns"); + assert.ok(placed.headerRectOf.get(groupKey(2, "component")), "and somewhere to land"); + assert.deepEqual(placed.headerRectOf.get(groupKey(2, "component")), placed.headers.find(header => header.key === groupKey(2, "component"))?.rect); + // Sources are the cards above, so a boundary fed by two files records both of them once each. + const deep = buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) }); + assert.deepEqual(deep.levels[3].find(group => group.boundary === "util")?.sources, ["file:src/components/Button.tsx"]); +}); + +test("a link bends along the axis its orientation reads in", () => { + const from = { x: 0, y: 0, width: 100, height: 40 }, to = { x: 200, y: 80, width: 100, height: 40 }; + assert.ok(flowLink(from, to, "columns").startsWith("M100 20"), "columns leave the right edge"); + assert.ok(flowLink(from, to, "tree").startsWith("M50 40"), "the tree leaves the bottom edge"); }); test("the heat scale is log-shaped, monotonic and clamped", () => { @@ -129,17 +193,6 @@ test("maxReach spans files and packages", () => { 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"), "entry", "an analyzer root is an entry point, not necessarily a screen"); assert.equal(boundaryOf("package:left-pad"), "package"); diff --git a/packages/ui/test/app-intelligence-view.test.ts b/packages/ui/test/app-intelligence-view.test.ts index f6640aa7..b4259f46 100644 --- a/packages/ui/test/app-intelligence-view.test.ts +++ b/packages/ui/test/app-intelligence-view.test.ts @@ -16,6 +16,10 @@ function fixture(): AppIntelSnapshot { { from: "file:app/index.tsx", to: "file:src/shared.ts", kind: "import", platforms: ["ios", "android"] }, { from: "file:app/profile.tsx", to: "file:src/shared.ts", kind: "import", platforms: ["ios", "android"] }, { from: "file:app/profile.tsx", to: "file:src/only.ios.ts", kind: "import", platforms: ["ios"] }, + { from: "file:app/profile.tsx", to: "file:src/components/Button.tsx", kind: "import", platforms: ["ios", "android"] }, + { from: "file:app/profile.tsx", to: "file:src/components/Card.tsx", kind: "import", platforms: ["ios", "android"] }, + { from: "file:app/profile.tsx", to: "file:src/hooks/useThing.ts", kind: "import", platforms: ["ios", "android"] }, + { from: "file:src/components/Button.tsx", to: "file:src/util/text.ts", kind: "import", platforms: ["ios", "android"] }, { from: "file:src/shared.ts", to: "package:left-pad", kind: "import", platforms: ["ios", "android"] }, ]; const indexes: AppIntelSnapshot["indexes"] = { screens: Object.create(null), incoming: Object.create(null), outgoing: Object.create(null) }; @@ -26,7 +30,7 @@ function fixture(): AppIntelSnapshot { 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) }, + files: { "app/index.tsx": file(1), "app/profile.tsx": file(1), "src/shared.ts": file(2), "src/only.ios.ts": file(1), "src/components/Button.tsx": file(1), "src/components/Card.tsx": file(1), "src/hooks/useThing.ts": file(1), "src/util/text.ts": file(1) }, packages: { "left-pad": { name: "left-pad", reach: 2, entry: "node_modules/left-pad/index.js" } }, screens: { home: { title: "Home", route: "/", source: "app/index.tsx", layouts: [] }, @@ -36,7 +40,7 @@ function fixture(): AppIntelSnapshot { }; } -test("App Intelligence renders the Atlas, hides packages by default and traces a screen in the Flow", async t => { +test("App Intelligence traces a screen, groups each hop by boundary and folds a group away", async t => { const dom = new JSDOM('
', { pretendToBeVisual: true, url: "http://localhost" }); const previous = new Map(); const globals = { @@ -95,90 +99,140 @@ test("App Intelligence renders the Atlas, hides packages by default and traces a await act(async () => { await new Promise(done => setTimeout(done, 0)); }); const query = (selector: string) => dom.window.document.querySelectorAll(selector); + const refs = () => [...query("[data-flow-node]")].map(node => node.getAttribute("data-flow-node")); + const railEntries = () => [...query("aside [data-entry]")].map(entry => entry.getAttribute("data-entry")); + const groups = () => [...query("[data-flow-group]")].map(node => node.getAttribute("data-flow-group")); assert.ok(dom.window.document.getElementById("app-intelligence-panel"), "the panel keeps the id the package smoke test asserts"); - const 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"); + // The picker is a dropdown over the remembered projects, so switching needs no folder picker. + const picker = dom.window.document.querySelector("[data-project-picker]") as HTMLSelectElement; + assert.equal(picker.tagName, "SELECT"); + assert.deepEqual([...picker.options].map(option => option.textContent), ["social-app", "other-app", "Choose another project…"]); + await act(async () => { + picker.value = "/tmp/other-app"; + picker.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + assert.deepEqual(calls.at(-1), { name: "mobile_select_app_intel_project", arguments: { projectPath: "/tmp/other-app" } }); + assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app"); + + // The Flow is the only view now: the rail lists entry points from the moment a snapshot loads. + const rail = dom.window.document.querySelector("aside .overflow-auto") as HTMLElement; + assert.equal(refs().length, 0, "no screen is selected yet"); assert.ok(query("[data-flow-filter]").length === 1, "the entry-point filter is there before anything is traced"); const screenButton = [...rail.querySelectorAll("button")].find(button => button.textContent?.includes("Profile")); - assert.ok(screenButton, "the rail lists screens in the Flow view"); + assert.ok(screenButton, "the rail lists screens"); 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"); + assert.deepEqual(refs(), ["screen:profile", "file:app/profile.tsx", "file:src/hooks/useThing.ts"], "the screen, its source and the first boundary under it"); + + // Each hop is split into boundary headers, and only the first opens by itself. + assert.deepEqual(groups(), ["1:other", "2:hook", "2:component", "2:other"], "every boundary is named, open or not"); + const group = (key: string) => dom.window.document.querySelector(`[data-flow-fold="${key}"]`) as Element; + assert.equal(group("2:component").getAttribute("aria-expanded"), "false"); + await act(async () => { group("2:component").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.deepEqual(panel.getSnapshot().toggled, ["2:component"]); + assert.equal(group("2:component").getAttribute("aria-expanded"), "true"); + assert.ok(refs().includes("file:src/components/Button.tsx"), "opening the boundary draws its rows"); + assert.deepEqual(groups().filter(key => key?.startsWith("3:")), ["3:util"], "and the hop under them, still folded"); + await act(async () => { group("2:component").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.deepEqual(panel.getSnapshot().toggled, [], "clicking it again folds it back to the default"); + assert.ok(!refs().includes("file:src/components/Button.tsx")); - assert.ok(!/\bHOP\b|\bROOT\b/.test(dom.window.document.querySelector("[data-flow-canvas]")?.textContent ?? ""), "the depth tint carries the hop, with no column headings"); + // Folding hides cards, never the shape: the connector moves onto the header instead. + const links = () => [...query("[data-flow-link]")].map(link => link.getAttribute("stroke-opacity")); + const foldedLinks = () => [...query("[data-flow-folded-link]")].map(link => link.getAttribute("data-flow-folded-link")); + assert.ok(foldedLinks().includes("2:component"), "a folded boundary keeps a connector into its header"); + assert.deepEqual([...new Set([...links(), ...[...query("[data-flow-folded-link]")].map(link => link.getAttribute("stroke-opacity"))])], ["0.2"], "everything is dim until something is lit"); - // The 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"); + // The pin on a header lights that one hop's boundary; the dot on a chip lights it at every hop. + const litOpacity = (key: string) => [...query(`[data-flow-folded-link="${key}"]`)].map(link => link.getAttribute("stroke-opacity")); + await act(async () => { (dom.window.document.querySelector('[data-flow-light="2:component"]') as Element).dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(panel.getSnapshot().litBoundary, "2:component"); + assert.deepEqual(litOpacity("2:component"), ["1"]); + assert.deepEqual(litOpacity("2:other"), ["0.2"], "the other folded boundaries stay dim"); + await act(async () => { (dom.window.document.querySelector('[data-flow-light="2:component"]') as Element).dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(panel.getSnapshot().litBoundary, null, "clicking the pin again dims it again"); + assert.deepEqual(litOpacity("2:component"), ["0.2"]); + const chipDot = dom.window.document.querySelector('[data-boundary-light="other"]') as HTMLButtonElement; + await act(async () => { chipDot.click(); }); + assert.equal(panel.getSnapshot().litBoundary, "*:other"); + assert.equal(chipDot.getAttribute("aria-pressed"), "true"); + assert.deepEqual(litOpacity("2:other"), ["1"], "a chip lights its boundary wherever it appears"); + assert.deepEqual(railEntries().sort(), ["home", "profile"], "and lighting it does not filter the rail"); + await act(async () => { chipDot.click(); }); - // The filter is the entry-point rail's filter: its chips come from what the entry points are, - // and unticking one takes those entries out of the rail without touching the traced graph. + // An overflow is a control, not a label: clicking it opens the rest of that boundary. + await act(async () => { panel.showAllIn("2:component"); }); + assert.deepEqual(panel.getSnapshot().expanded, ["2:component"]); + + // The orientation toggle re-reads the same graph downwards instead of across. + const svg = () => dom.window.document.querySelector("[data-flow-orientation]") as SVGElement; + assert.equal(svg().getAttribute("data-flow-orientation"), "columns"); + const before = refs(); + await act(async () => { (dom.window.document.querySelector('[data-orientation="tree"]') as HTMLButtonElement).click(); }); + assert.equal(svg().getAttribute("data-flow-orientation"), "tree"); + assert.deepEqual(refs(), before, "the toggle changes the reading direction, not the graph"); + assert.equal(panel.getSnapshot().orientation, "tree"); + + // The filter is the entry-point rail's filter: unticking a boundary takes those entries out of + // the rail without touching the traced graph. const chips = () => [...query("[data-flow-filter] [data-boundary]")].map(chip => chip.getAttribute("data-boundary")); - const railEntries = () => [...query("aside [data-entry]")].map(entry => entry.getAttribute("data-entry")); assert.deepEqual(chips(), ["all", "other"], "both fixture entries render plain source files"); assert.deepEqual(railEntries().sort(), ["home", "profile"]); const other = dom.window.document.querySelector('[data-flow-filter] [data-boundary="other"]') as HTMLButtonElement; await act(async () => { other.click(); }); - assert.equal(other.getAttribute("aria-pressed"), "false"); assert.deepEqual(railEntries(), [], "unticking the only boundary empties the rail"); - assert.match((dom.window.document.querySelector("aside") as HTMLElement).textContent ?? "", /0 entry points/, "the header counts what the rail shows"); - assert.ok([...query("[data-flow-node]")].length > 0, "the filter is the rail's, so the traced graph still draws in full"); + assert.ok(refs().length > 0, "the filter is the rail's, so the traced graph still draws in full"); await act(async () => { (dom.window.document.querySelector('[data-flow-filter] [data-boundary="all"]') as HTMLButtonElement).click(); }); assert.deepEqual(railEntries().sort(), ["home", "profile"], "All brings them back"); - // Dragging pans the board itself, which is what the old scroll-based pan failed to do when the - // graph already fitted its pane. The drag must not re-root the graph on the card underneath. + // Dragging pans the board itself, which is what a scroll-based pan failed to do when the graph + // already fitted its pane. The drag must not re-root the graph on the card underneath. const canvas = dom.window.document.querySelector("[data-flow-canvas]") as HTMLElement; const board = canvas.firstElementChild as HTMLElement; const pointer = (type: string, x: number, y: number, target: EventTarget) => target.dispatchEvent(new dom.window.MouseEvent(type, { clientX: x, clientY: y, bubbles: true, button: 0 })); - assert.equal(board.style.transform, "translate(0px, 0px)"); - const rooted = panel.getSnapshot().flowRoot; + assert.equal(board.style.transform, "translate(0px, 0px) scale(1)"); await act(async () => { pointer("pointerdown", 300, 200, canvas); pointer("pointermove", 220, 170, dom.window); pointer("pointerup", 220, 170, dom.window); }); - assert.equal(board.style.transform, "translate(-80px, -30px)", "the board follows the pointer"); - await act(async () => { (dom.window.document.querySelector("[data-flow-node]") as Element).dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); - assert.equal(panel.getSnapshot().flowRoot, rooted, "the click that ends a drag is swallowed"); + assert.equal(board.style.transform, "translate(-80px, -30px) scale(1)", "the board follows the pointer"); + const folds = panel.getSnapshot().toggled.length; + await act(async () => { group("2:other").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(panel.getSnapshot().toggled.length, folds, "the click that ends a drag is swallowed"); + // Folding a boundary is a re-render, and must not move the board the reader panned. + await act(async () => { group("2:other").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(board.style.transform, "translate(-80px, -30px) scale(1)", "a fold leaves the view where it was"); await act(async () => { canvas.dispatchEvent(new dom.window.MouseEvent("dblclick", { bubbles: true })); }); - assert.equal(board.style.transform, "translate(0px, 0px)", "double-click recentres"); + assert.equal(board.style.transform, "translate(0px, 0px) scale(1)", "double-click resets"); - // The picker is a dropdown over the remembered projects, so switching needs no folder picker. - const picker = dom.window.document.querySelector("[data-project-picker]") as HTMLSelectElement; - assert.equal(picker.tagName, "SELECT"); - assert.deepEqual([...picker.options].map(option => option.textContent), ["social-app", "other-app", "Choose another project…"]); - assert.equal(picker.value, "/tmp/social-app", "the current project is the one shown"); - await act(async () => { - picker.value = "/tmp/other-app"; - picker.dispatchEvent(new dom.window.Event("change", { bubbles: true })); - }); - assert.deepEqual(calls.at(-1), { name: "mobile_select_app_intel_project", arguments: { projectPath: "/tmp/other-app" } }); - assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app"); + // Zoom is the buttons and the readout beside them, on the same transform as the pan. + const zoom = (which: string) => dom.window.document.querySelector(`[data-zoom="${which}"]`) as HTMLButtonElement; + const level = () => (dom.window.document.querySelector("[data-zoom-level]") as HTMLElement).textContent; + assert.equal(level(), "100%"); + await act(async () => { zoom("in").click(); }); + assert.equal(level(), "125%"); + assert.match(board.style.transform, /scale\(1\.25\)/); + await act(async () => { zoom("out").click(); zoom("out").click(); }); + assert.equal(level(), "80%"); + await act(async () => { for (let press = 0; press < 12; press++) zoom("out").click(); }); + assert.equal(level(), "30%", "zooming out stops at the floor"); + await act(async () => { zoom("reset").click(); }); + assert.equal(board.style.transform, "translate(0px, 0px) scale(1)", "reset returns both pan and zoom"); + + // A card is not a control: clicking one leaves the graph rooted on the screen the rail picked. + await act(async () => { group("2:component").dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + const card = [...query("[data-flow-node]")].find(node => node.getAttribute("data-flow-node") === "file:src/components/Button.tsx") as Element; + assert.equal(card.getAttribute("role"), "img", "and it is not announced as one either"); + await act(async () => { card.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); + assert.equal(refs()[0], "screen:profile", "the root is still the screen"); + assert.equal(panel.getSnapshot().selectedScreen, "profile"); - // 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"); + // Picking another screen is the only way to re-root, and it forgets the folds of the old one. + const home = [...rail.querySelectorAll("button")].find(button => button.textContent?.includes("Home")) as HTMLButtonElement; + await act(async () => { home.click(); }); + assert.equal(refs()[0], "screen:home"); + assert.deepEqual(panel.getSnapshot().toggled, [], "the folds belonged to the old screen"); + assert.deepEqual(panel.getSnapshot().expanded, []); }); diff --git a/packages/ui/test/ui-telemetry.test.ts b/packages/ui/test/ui-telemetry.test.ts index fe692a8c..535a64a7 100644 --- a/packages/ui/test/ui-telemetry.test.ts +++ b/packages/ui/test/ui-telemetry.test.ts @@ -740,22 +740,26 @@ test("App Intelligence layout measurements carry counts only, never paths", asyn 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.orientation_switch"); + api.countUiEvent("ui.app_intel.group_collapse"); + api.countUiEvent("ui.app_intel.group_expand"); + api.countUiEvent("ui.app_intel.group_show_all"); + api.countUiEvent("ui.app_intel.boundary_highlight"); + api.countUiEvent("ui.app_intel.project_switch"); api.countUiEvent("ui.app_intel.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.orientation_switch"); + contains(encoded, "ui.app_intel.group_collapse"); + contains(encoded, "ui.app_intel.group_expand"); + contains(encoded, "ui.app_intel.group_show_all"); + contains(encoded, "ui.app_intel.boundary_highlight"); + contains(encoded, "ui.app_intel.project_switch"); contains(encoded, "ui.app_intel.boundary_filter"); contains(encoded, "app-intel"); contains(encoded, "/Users/", false); From 585b71142cc384302483ae9d5900c1fe28537dad Mon Sep 17 00:00:00 2001 From: Hur Ali Date: Thu, 5 Nov 2026 15:05:44 +0500 Subject: [PATCH 4/9] feat: ui updates --- apps/mac/Sources/MobileDev/Tool.swift | 2 +- docs/telemetry.md | 10 +- packages/core/src/app-intelligence/service.ts | 9 +- packages/core/src/app-intelligence/storage.ts | 7 +- packages/core/src/app-intelligence/tools.ts | 9 ++ packages/core/src/telemetry.ts | 2 +- packages/mcp-app/src/components/workspace.tsx | 2 +- packages/ui/src/app-intelligence-layout.ts | 103 +++--------------- packages/ui/src/app-intelligence-panel.ts | 19 +++- .../src/components/app-intelligence-view.tsx | 85 ++++++++------- packages/ui/src/components/confirm-button.tsx | 24 ++++ packages/ui/src/components/storage-browse.tsx | 3 +- packages/ui/src/components/storage-parts.tsx | 16 --- packages/ui/src/components/storage-view.tsx | 3 +- .../ui/test/app-intelligence-layout.test.ts | 72 ++++-------- .../ui/test/app-intelligence-view.test.ts | 45 +++++--- packages/ui/test/ui-telemetry.test.ts | 4 +- tooling/hosts/hosts.spec.ts | 2 +- tooling/hosts/visual.spec.ts | 2 +- 19 files changed, 186 insertions(+), 233 deletions(-) create mode 100644 packages/ui/src/components/confirm-button.tsx diff --git a/apps/mac/Sources/MobileDev/Tool.swift b/apps/mac/Sources/MobileDev/Tool.swift index 660a0037..25df1d52 100644 --- a/apps/mac/Sources/MobileDev/Tool.swift +++ b/apps/mac/Sources/MobileDev/Tool.swift @@ -13,7 +13,7 @@ enum Tool: String, CaseIterable, Identifiable { case .logs: "Logs" case .performance: "Performance" case .storage: "Storage" - case .appIntel: "App Intelligence" + case .appIntel: "App Intel" case .tracesift: "TraceSift" } } diff --git a/docs/telemetry.md b/docs/telemetry.md index 6393a4db..3d376d1b 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -656,8 +656,8 @@ 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 +`.samples` measure bounded generation, query, snapshot, clear and folder-picker +operations by success, failure or cancellation. The UI records generation/load/picker round trips, surface readiness, and gauges only the counts of files and screens in the displayed snapshot. `ui.app_intel.flow_layout` times building and placing the per-screen dependency graph — the hop layering, its boundary grouping and the @@ -667,12 +667,12 @@ directory treemap, so the Atlas measurements `ui.app_intel.atlas_layout`, `ui.app_intel.atlas_cells`, `ui.app_intel.view_switch` and `ui.app_intel.zoom` are gone rather than renamed; `flow_layout` and `flow_nodes` keep their meaning and units, so their history stays comparable even though they now cover grouping and -placement as well. `ui.app_intel.orientation_switch` counts switching between the -column and top-down readings, `ui.app_intel.group_collapse`, `.group_expand` and +placement as well. `ui.app_intel.group_collapse`, `.group_expand` and `.group_show_all` count folding a boundary away, bringing it back and opening one past its row cap, `ui.app_intel.boundary_highlight` counts lighting one boundary's connectors up, `ui.app_intel.project_switch` counts picking a remembered -project instead of the host folder picker, and `ui.app_intel.boundary_filter` +project instead of the host folder picker, `ui.app_intel.clear` counts throwing a +saved graph away, and `ui.app_intel.boundary_filter` counts how often the entry-point filter is changed. All of these count the change only; which boundary, project or direction was picked is not an attribute. Project paths, source paths, screen and package names, source contents, queries, diagnostics, graph edges and snapshot identifiers remain local diff --git a/packages/core/src/app-intelligence/service.ts b/packages/core/src/app-intelligence/service.ts index 093a3521..76527d91 100644 --- a/packages/core/src/app-intelligence/service.ts +++ b/packages/core/src/app-intelligence/service.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { Worker } from "node:worker_threads"; import type { AppIntelSnapshot, AppIntelStatus } from "@mobile-dev/shared/app-intelligence.ts"; -import { readAppIntel, readRecentProjects, readRememberedProject, rememberProject, validateProjectPath, writeAppIntel } from "./storage.ts"; +import { clearAppIntel, readAppIntel, readRecentProjects, readRememberedProject, rememberProject, validateProjectPath, writeAppIntel } from "./storage.ts"; type Run = NonNullable & { controller: AbortController; worker?: Worker }; @@ -87,6 +87,13 @@ export class AppIntelligenceService { } finally { signal?.removeEventListener("abort", abort); this.runs.delete(projectPath); } } + async clear(path?: string): Promise<{ projectPath: string; snapshot: null; recents: string[] }> { + const projectPath = await this.project(path); + if (this.runs.has(projectPath)) throw new Error("App Intelligence is still generating for this project. Cancel it first."); + await clearAppIntel(projectPath); + return { projectPath, snapshot: null, recents: await readRecentProjects() }; + } + async cancel(path?: string) { const projectPath = await this.project(path); const run = this.runs.get(projectPath); diff --git a/packages/core/src/app-intelligence/storage.ts b/packages/core/src/app-intelligence/storage.ts index 183d94d1..79d555e7 100644 --- a/packages/core/src/app-intelligence/storage.ts +++ b/packages/core/src/app-intelligence/storage.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import type { AppIntelSnapshot } from "@mobile-dev/shared/app-intelligence.ts"; @@ -65,3 +65,8 @@ export async function writeAppIntel(projectPath: string, snapshot: AppIntelSnaps await atomicWrite(snapshotPath(projectPath), JSON.stringify(snapshot)); } +/** Drops one project's saved graph. The project stays remembered, so it can be generated again. */ +export async function clearAppIntel(projectPath: string) { + await rm(snapshotPath(projectPath), { force: true }); +} + diff --git a/packages/core/src/app-intelligence/tools.ts b/packages/core/src/app-intelligence/tools.ts index fd5364e7..656b974b 100644 --- a/packages/core/src/app-intelligence/tools.ts +++ b/packages/core/src/app-intelligence/tools.ts @@ -85,6 +85,15 @@ export function registerAppIntelligenceTools(server: McpServer, service: AppInte catch (error) { return failure(error); } }); + registerAppTool(server, "mobile_clear_app_intel", { + title: "Clear the App Intelligence graph", description: "Delete the saved App Intelligence graph for the selected project. The project stays selected and can be analyzed again.", + inputSchema: generateAppIntelInput.shape, annotations: write, _meta: { ui: { visibility: ["app"] } }, + }, async input => { + const startedAt = performance.now(); + try { const result = text(await service.clear(input.projectPath)); recordAppIntelOperation("clear", "success", performance.now() - startedAt); return result; } + catch (error) { recordAppIntelOperation("clear", "failed", performance.now() - startedAt); return failure(error); } + }); + registerAppTool(server, "mobile_cancel_app_intel", { title: "Cancel App Intelligence generation", description: "Cancel App Intelligence generation before its atomic save.", inputSchema: generateAppIntelInput.shape, annotations: write, _meta: { ui: { visibility: ["app"] } }, diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts index b20145d0..34270041 100644 --- a/packages/core/src/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -53,7 +53,7 @@ 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) { +export function recordAppIntelOperation(operation: "generate" | "query" | "snapshot" | "pick" | "clear", outcome: "success" | "failed" | "cancelled", duration: number) { if (process.env.MOBILE_DEV_TELEMETRY === "off") return; const attributes = { component: "server", surface: "app-intel", operation, outcome }; Sentry.metrics.count("app_intel.operation.samples", 1, { attributes }); diff --git a/packages/mcp-app/src/components/workspace.tsx b/packages/mcp-app/src/components/workspace.tsx index 979afe2e..c950d2f3 100644 --- a/packages/mcp-app/src/components/workspace.tsx +++ b/packages/mcp-app/src/components/workspace.tsx @@ -26,7 +26,7 @@ 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 }, + "app-intel": { id: "tool-app-intel", label: "App Intel", icon: NetworkIcon }, tracesift: { id: "tool-tracesift", label: "TraceSift", icon: ChartNoAxesCombinedIcon }, }; type Tool = keyof typeof tools; diff --git a/packages/ui/src/app-intelligence-layout.ts b/packages/ui/src/app-intelligence-layout.ts index 670c335d..1ab54594 100644 --- a/packages/ui/src/app-intelligence-layout.ts +++ b/packages/ui/src/app-intelligence-layout.ts @@ -18,13 +18,6 @@ export function platformsFor(snapshot: AppIntelSnapshot, ref: IntelRef): NodePla 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; -} - 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; @@ -64,27 +57,6 @@ export function clampName(text: string, max: number): string { return text.length <= max ? text : `${text.slice(0, max - 1)}…`; } -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 graph 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(",")})`; -} - -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)]; } @@ -278,19 +250,18 @@ export function buildFlow(snapshot: AppIntelSnapshot, start: IntelRef, options: // ── Placing it ──────────────────────────────────────────────────────────────────────────────── -export type FlowOrientation = "columns" | "tree"; export type FlowMetrics = { card: { width: number; height: number }; gap: number; header: number; headerGap: number; - groupGap: number; column: number; rowGap: number; pad: number; indent: number; more: number; perRow: number; + groupGap: number; column: number; pad: number; indent: number; more: number; }; /** - * `column` and `rowGap` are deliberately wide: the links between hops fan out across the whole - * gap, and at the old 52px they crossed each other into a thicket before reaching their cards. + * `column` is deliberately wide: the links between hops fan out across the whole gap, and at the + * old 52px they crossed each other into a thicket before reaching their cards. */ export const FLOW_METRICS: FlowMetrics = { card: { width: 226, height: 40 }, gap: 8, header: 22, headerGap: 6, - groupGap: 20, column: 376, rowGap: 92, pad: 16, indent: 10, more: 22, perRow: 4, + groupGap: 20, column: 376, pad: 16, indent: 10, more: 22, }; export type PlacedHeader = { key: string; boundary: Boundary; depth: number; count: number; shown: number; collapsed: boolean; sources: IntelRef[]; rect: Rect }; @@ -328,60 +299,21 @@ function columnBlock(level: FlowGroup[], depth: number, metrics: FlowMetrics): B return block; } -/** The same level laid out as a row: boundaries side by side, their rows wrapping under them. */ -function treeBlock(level: FlowGroup[], depth: number, metrics: FlowMetrics): Block { - const { card, gap, header, headerGap, groupGap, more, perRow } = metrics; - const block: Block = { headers: [], cards: [], more: [], width: 0, height: 0 }; - let x = 0; - for (const group of level) { - const across = Math.min(perRow, Math.max(1, group.refs.length)); - const width = across * (card.width + gap) - gap; - block.headers.push({ key: group.key, boundary: group.boundary, depth, count: group.count, shown: group.refs.length, collapsed: group.collapsed, sources: group.sources, rect: { x, y: 0, width, height: header } }); - const top = header + headerGap; - group.refs.forEach((ref, index) => block.cards.push({ - ref, depth, - rect: { x: x + (index % perRow) * (card.width + gap), y: top + Math.floor(index / perRow) * (card.height + gap), width: card.width, height: card.height }, - })); - let bottom = top + Math.ceil(group.refs.length / perRow) * (card.height + gap); - if (group.hidden) { - block.more.push({ key: group.key, boundary: group.boundary, depth, hidden: group.hidden, sources: group.sources, rect: { x, y: bottom, width: card.width, height: more } }); - bottom += more + gap; - } - block.height = Math.max(block.height, bottom - gap); - x += width + groupGap; - } - block.width = Math.max(0, x - groupGap); - return block; -} - function shift(block: Block, dx: number, dy: number) { for (const item of [...block.headers, ...block.cards, ...block.more]) { item.rect.x += dx; item.rect.y += dy; } } -/** - * Places a built graph for either reading direction. "columns" is one boundary stack per hop, - * read left to right; "tree" stacks the hops downwards, each one a row of boundaries. Levels are - * centred on the cross axis so the root sits against the middle of what it pulls in. - */ -export function placeFlow(graph: FlowGraph, orientation: FlowOrientation, metrics: FlowMetrics = FLOW_METRICS): FlowPlacement { - const make = orientation === "columns" ? columnBlock : treeBlock; +/** Places a built graph: one boundary stack per hop, read left to right. */ +export function placeFlow(graph: FlowGraph, metrics: FlowMetrics = FLOW_METRICS): FlowPlacement { const blocks = graph.levels.map((level, depth) => depth === 0 ? { headers: [], cards: [{ ref: level[0].refs[0], depth: 0, rect: { x: 0, y: 0, width: metrics.card.width, height: metrics.card.height } }], more: [], width: metrics.card.width, height: metrics.card.height } satisfies Block - : make(level, depth, metrics)); + : columnBlock(level, depth, metrics)); // Every level is anchored to the start of the cross axis rather than centred on it. Centring // reads better on a still graph, but folding one boundary then moves every other level, which // throws away where the reader had panned to. A fixed origin keeps folds local. - let width = 0, height = 0; - if (orientation === "columns") { - blocks.forEach((block, depth) => shift(block, metrics.pad + depth * metrics.column, metrics.pad)); - width = metrics.pad * 2 + (blocks.length - 1) * metrics.column + metrics.card.width; - height = metrics.pad * 2 + Math.max(0, ...blocks.map(block => block.height)); - } else { - let y = metrics.pad; - for (const block of blocks) { shift(block, metrics.pad, y); y += block.height + metrics.rowGap; } - width = metrics.pad * 2 + Math.max(0, ...blocks.map(block => block.width)); - height = Math.max(metrics.pad * 2, y - metrics.rowGap + metrics.pad); - } + blocks.forEach((block, depth) => shift(block, metrics.pad + depth * metrics.column, metrics.pad)); + const width = metrics.pad * 2 + (blocks.length - 1) * metrics.column + metrics.card.width; + const height = metrics.pad * 2 + Math.max(0, ...blocks.map(block => block.height)); const placement: FlowPlacement = { width, height, headers: [], cards: [], more: [], rectOf: new Map(), headerRectOf: new Map() }; for (const block of blocks) { placement.headers.push(...block.headers); @@ -393,14 +325,9 @@ export function placeFlow(graph: FlowGraph, orientation: FlowOrientation, metric return placement; } -/** The curve between two placed cards, bent along the axis the orientation reads in. */ -export function flowLink(from: Rect, to: Rect, orientation: FlowOrientation): string { - if (orientation === "columns") { - const x1 = from.x + from.width, y1 = from.y + from.height / 2, x2 = to.x, y2 = to.y + to.height / 2; - const bend = Math.max(8, (x2 - x1) * 0.45); - return `M${x1} ${y1} C${x1 + bend} ${y1} ${x2 - bend} ${y2} ${x2} ${y2}`; - } - const x1 = from.x + from.width / 2, y1 = from.y + from.height, x2 = to.x + to.width / 2, y2 = to.y; - const bend = Math.max(8, (y2 - y1) * 0.45); - return `M${x1} ${y1} C${x1} ${y1 + bend} ${x2} ${y2 - bend} ${x2} ${y2}`; +/** The curve from one placed box's right edge to the next one's left. */ +export function flowLink(from: Rect, to: Rect): string { + const x1 = from.x + from.width, y1 = from.y + from.height / 2, x2 = to.x, y2 = to.y + to.height / 2; + const bend = Math.max(8, (x2 - x1) * 0.45); + return `M${x1} ${y1} C${x1 + bend} ${y1} ${x2 - bend} ${y2} ${x2} ${y2}`; } diff --git a/packages/ui/src/app-intelligence-panel.ts b/packages/ui/src/app-intelligence-panel.ts index ddbdab63..fa91b6e2 100644 --- a/packages/ui/src/app-intelligence-panel.ts +++ b/packages/ui/src/app-intelligence-panel.ts @@ -4,12 +4,10 @@ import { captureUiError, countUiEvent, measureUiOperation, recordUiTiming, setUi export type AppIntelligenceState = { open: boolean; available: boolean; projectPath: string; snapshot: AppIntelSnapshot | null; - run: AppIntelStatus["run"]; busy: "loading" | "picking" | "generating" | "cancelling" | null; + run: AppIntelStatus["run"]; busy: "loading" | "picking" | "generating" | "cancelling" | "clearing" | null; error: string; selectedScreen: string | null; /** Projects the panel remembers, most recent first, so switching needs no folder picker. */ recents: string[]; - /** Which way the Flow reads: one boundary stack per hop, or the hops stacked downwards. */ - orientation: "columns" | "tree"; /** * Boundary groups flipped away from their default fold (only the first hop opens by itself), * and those opened past their row cap. Both are keyed ":". @@ -30,7 +28,7 @@ function resultError(result: Awaited>) export class AppIntelligencePanel { private listeners = new Set<() => void>(); - private state: AppIntelligenceState = { open: false, available: false, projectPath: "", snapshot: null, run: null, busy: null, error: "", selectedScreen: null, recents: [], orientation: "columns", toggled: [], expanded: [], litBoundary: null, boundaries: null }; + private state: AppIntelligenceState = { open: false, available: false, projectPath: "", snapshot: null, run: null, busy: null, error: "", selectedScreen: null, recents: [], toggled: [], expanded: [], litBoundary: null, boundaries: null }; private app: HostBridge; constructor(app: HostBridge) { this.app = app; } subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); }; @@ -42,7 +40,6 @@ export class AppIntelligencePanel { dispose() { this.listeners.clear(); } // A new root means new hops, so the folds from the old one would land on unrelated boundaries. selectScreen(selectedScreen: string | null) { this.update({ selectedScreen, toggled: [], expanded: [], litBoundary: null }); } - setOrientation(orientation: AppIntelligenceState["orientation"]) { if (orientation !== this.state.orientation) { countUiEvent("ui.app_intel.orientation_switch"); this.update({ orientation }); } } /** `open` is what the click is asking for; the state stores only the flip from the default. */ toggleGroup(key: string, open: boolean) { countUiEvent(open ? "ui.app_intel.group_expand" : "ui.app_intel.group_collapse"); @@ -132,6 +129,18 @@ export class AppIntelligencePanel { } } + /** Throws the saved graph away and leaves the project selected, ready to generate again. */ + async clear() { + if (!this.state.available || !this.state.projectPath || this.state.busy !== null) return; + this.update({ busy: "clearing", error: "" }); + try { + const result = await this.app.callServerTool({ name: "mobile_clear_app_intel", arguments: { projectPath: this.state.projectPath } }); + if (result.isError) throw new Error(resultError(result)); + countUiEvent("ui.app_intel.clear"); + this.update({ busy: null, snapshot: null, selectedScreen: null, toggled: [], expanded: [], litBoundary: null, boundaries: null }); + } catch (error) { this.update({ busy: null, error: error instanceof Error ? error.message : String(error) }); captureUiError(error, "app_intel.clear"); } + } + async cancel() { if (this.state.busy !== "generating") return; this.update({ busy: "cancelling" }); diff --git a/packages/ui/src/components/app-intelligence-view.tsx b/packages/ui/src/components/app-intelligence-view.tsx index 04301ab4..77e5aa70 100644 --- a/packages/ui/src/components/app-intelligence-view.tsx +++ b/packages/ui/src/components/app-intelligence-view.tsx @@ -1,21 +1,24 @@ -import { ColumnsIcon, FolderOpenIcon, ListFilterIcon, MaximizeIcon, MinusIcon, NetworkIcon, PlusIcon, RefreshCwIcon, RowsIcon, SearchIcon, SquareIcon } from "lucide-react"; +import { EraserIcon, FolderOpenIcon, ListFilterIcon, MaximizeIcon, MinusIcon, NetworkIcon, PlusIcon, RefreshCwIcon, SearchIcon, SquareIcon } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { AppIntelligencePanel } from "../app-intelligence-panel.ts"; import type { AppIntelSnapshot, IntelRef } from "@mobile-dev/shared/app-intelligence.ts"; import { fileRef, screenRef } from "@mobile-dev/shared/app-intelligence.ts"; -import type { Boundary, FlowOrientation, NodePlatforms } from "../app-intelligence-layout.ts"; -import { boundariesIn, boundaryColor, boundaryLabel, boundaryOf, buildFlow, clampName, depthHue, depthTint, ellipsize, flowLink, heat, heatRamp, heatT, labelOf, maxReach, pathOf, placeFlow, platformsFor, reachOf, FLOW_METRICS } from "../app-intelligence-layout.ts"; +import type { Boundary, NodePlatforms } from "../app-intelligence-layout.ts"; +import { boundariesIn, boundaryColor, boundaryLabel, boundaryOf, buildFlow, clampName, depthHue, depthTint, ellipsize, flowLink, labelOf, pathOf, placeFlow, platformsFor, reachOf, FLOW_METRICS } from "../app-intelligence-layout.ts"; import { recordUiTiming, setUiGauge } from "../telemetry.ts"; import { Alert, AlertDescription } from "./ui/alert.tsx"; import { Button } from "./ui/button.tsx"; +import { ConfirmButton } from "./confirm-button.tsx"; import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group.tsx"; -import { NativeSelect, NativeSelectOption } from "./ui/native-select.tsx"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/base-select.tsx"; const BROWSE = "\u0000browse"; +const folderName = (path: string) => path.split("/").at(-1) || path; /** - * The project picker. It remembers what has been opened, so switching between apps is a dropdown - * rather than a trip through the host folder picker; the last entry still opens that picker. + * The project picker, on the same Select as the workspace's tool and platform menus. It remembers + * what has been opened, so switching between apps is a menu rather than a trip through the host + * folder picker; the last entry still opens that picker. */ function ProjectPicker({ projectPath, recents, disabled, onSelect, onBrowse }: { projectPath: string; recents: readonly string[]; disabled: boolean; @@ -23,14 +26,23 @@ function ProjectPicker({ projectPath, recents, disabled, onSelect, onBrowse }: { }) { const options = [...new Set([...(projectPath ? [projectPath] : []), ...recents])]; if (!options.length) return ; - return
- - event.target.value === BROWSE ? onBrowse() : onSelect(event.target.value)}> - {options.map(path => {path.split("/").at(-1) || path})} - Choose another project… - -
; + return ; } const platformEdge: Record = { ios: "bg-sky-500", android: "bg-lime-500", both: "bg-transparent" }; @@ -166,8 +178,8 @@ const pathChars = (boundary: string) => Math.max(8, Math.floor((FLOW_METRICS.car /** The three weights a connector is drawn at: dim by default, warmer on hover, full when lit. */ const LINK = { dim: 0.2, hovered: 0.6, lit: 1 } as const; -function FlowView({ snapshot, start, max, orientation, toggled, expanded, litBoundary, height, onToggleGroup, onLight, onShowAll, onHover }: { - snapshot: AppIntelSnapshot; start: IntelRef; max: number; orientation: FlowOrientation; +function FlowView({ snapshot, start, toggled, expanded, litBoundary, height, onToggleGroup, onLight, onShowAll, onHover }: { + snapshot: AppIntelSnapshot; start: IntelRef; toggled: ReadonlySet; expanded: ReadonlySet; litBoundary: string | null; height: number; onToggleGroup: (key: string, open: boolean) => void; onLight: (key: string) => void; onShowAll: (key: string) => void; onHover: (hover: Hover) => void; @@ -176,10 +188,10 @@ function FlowView({ snapshot, start, max, orientation, toggled, expanded, litBou const { graph, placement } = useMemo(() => { const startedAt = performance.now(); const built = buildFlow(snapshot, start, { toggled, expanded }); - const placed = placeFlow(built, orientation); + const placed = placeFlow(built); recordUiTiming("ui.app_intel.flow_layout", performance.now() - startedAt); return { graph: built, placement: placed }; - }, [snapshot, start, orientation, toggled, expanded]); + }, [snapshot, start, toggled, expanded]); useEffect(() => { setUiGauge("ui.app_intel.flow_nodes", placement.cards.length); }, [placement]); const path = new Set(); @@ -190,13 +202,13 @@ function FlowView({ snapshot, start, max, orientation, toggled, expanded, litBou isLit(boundary, depth) ? LINK.lit : hovered ? LINK.hovered : LINK.dim; return + role="group" aria-label="Dependency flow" className="block select-none"> {placement.cards.map(({ ref, depth, rect }) => { const from = graph.parent.get(ref); const origin = from && placement.rectOf.get(from); if (!origin) return null; const opacity = weight(boundaryOf(ref), depth, path.has(ref)); - return ; })} {/* A folded boundary keeps its connectors: they land on the header instead of on its rows, @@ -207,7 +219,7 @@ function FlowView({ snapshot, start, max, orientation, toggled, expanded, litBou const origin = placement.rectOf.get(source); if (!origin) return null; const opacity = weight(entry.boundary, entry.depth, false); - return ; }))} @@ -247,7 +259,6 @@ function FlowView({ snapshot, start, max, orientation, toggled, expanded, litBou const platforms = screen ? "both" as NodePlatforms : platformsFor(snapshot, ref); const boundary = boundaryOf(ref); const detail = `${boundaryLabel[boundary]}${screen ? ` · ${screen.route ?? ref.slice(7)}` : ` · reach ${reach}`}${platforms === "both" ? "" : ` · ${platformLabel(platforms)}`}`; - const barWidth = Math.max(3, 44 * heatT(reach, max)); const on = path.has(ref); return { setLit(ref); onHover({ title: ref.replace(/^(?:file|package|screen):/, ""), detail, x: event.clientX, y: event.clientY }); }} @@ -260,10 +271,7 @@ function FlowView({ snapshot, start, max, orientation, toggled, expanded, litBou {clampName(labelOf(ref), NAME_CHARS)} {boundary} {ellipsize(screen ? screen.route ?? "" : pathOf(ref), pathChars(boundary))} - {!screen && <> - {reach} - - } + {!screen && {reach}} ; })} ; @@ -299,8 +307,6 @@ function BoundaryFilter({ available, selected, lit, onToggle, onLight, onAll }: ; } -const ORIENTATIONS = [["columns", "Columns", ColumnsIcon], ["tree", "Top-down", RowsIcon]] as const; - export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: { panel: AppIntelligencePanel }) { const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot); const [query, setQuery] = useState(""); @@ -309,7 +315,6 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: const snapshot = state.snapshot; const search = query.trim().toLowerCase(); - const max = useMemo(() => snapshot ? maxReach(snapshot) : 1, [snapshot]); // The rail is what roots the graph. A card is a place in that screen's tree, not a tree of its // own: re-rooting on a click meant any stray click threw away where the reader had got to. const selected = state.selectedScreen && snapshot?.screens[state.selectedScreen] ? state.selectedScreen : null; @@ -324,7 +329,7 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: const selectedBoundaries = useMemo(() => new Set(state.boundaries ?? available.map(entry => entry.boundary)), [state.boundaries, available]); const toggleBoundary = useCallback((boundary: Boundary) => panel.toggleBoundary(boundary, available.map(entry => entry.boundary)), [panel, available]); // Re-centre the canvas whenever the graph it holds changes, so a new trace is never panned away. - const { zoom, zoomIn, zoomOut, reset } = usePanZoom(flowBox, `${flowStart ?? ""}:${state.orientation}`); + const { zoom, zoomIn, zoomOut, reset } = usePanZoom(flowBox, flowStart ?? ""); const screens = useMemo(() => entries.filter(({ id, screen, boundary }) => selectedBoundaries.has(boundary) && (!search || `${id} ${screen.title} ${screen.route ?? ""} ${screen.source ?? ""}`.toLowerCase().includes(search))), [entries, selectedBoundaries, search]); @@ -332,13 +337,16 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }:
void panel.selectProject(path)} onBrowse={() => void panel.pickProject()} /> - {snapshot &&
- {ORIENTATIONS.map(([value, label, Icon]) => )} -
} setQuery(event.target.value)} /> - {state.busy === "generating" || state.busy === "cancelling" ? : } + {state.busy === "generating" || state.busy === "cancelling" + ? + : <> + + {snapshot && void panel.clear()} + className="text-destructive hover:bg-destructive/10 hover:text-destructive">Clear} + }
{state.error && {state.error}} {state.run &&
{state.run.message}
} @@ -348,8 +356,7 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }:
{snapshot.screens[selected ?? ""]?.title ?? "Choose a screen"} -
reach{max}
-
+
{Math.round(zoom * 100)}% @@ -359,7 +366,7 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: panel.litBoundaryTo(`*:${boundary}`)} onAll={() => panel.setBoundaries(null)} />
- {flowStart ? panel.toggleGroup(key, open)} onLight={key => panel.litBoundaryTo(key)} onShowAll={key => panel.showAllIn(key)} onHover={setHover} /> :
Choose an entry point to trace its dependencies.
}
diff --git a/packages/ui/src/components/confirm-button.tsx b/packages/ui/src/components/confirm-button.tsx new file mode 100644 index 00000000..b6bc14a3 --- /dev/null +++ b/packages/ui/src/components/confirm-button.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import { Button } from "./ui/button"; +import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "./ui/popover"; + +/** + * A button that asks before it acts, in a popover anchored to itself. Shared by every panel with + * a destructive control, so "clear this" reads and behaves the same wherever it appears. + */ +export function ConfirmButton({ children, title, description, action, disabled, onConfirm, className, variant = "ghost", size = "xs", id }: { + children: ReactNode; title: string; description: string; action: string; disabled?: boolean; + onConfirm: () => void; className?: string; variant?: "ghost" | "outline"; size?: "xs" | "sm"; id?: string; +}) { + return + + +

{title}?

+

{description}

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

{title}?

-

{description}

-
- - -
-
-
; -} - export function StorageEmpty({ title, description }: { title: string; description: string }) { return diff --git a/packages/ui/src/components/storage-view.tsx b/packages/ui/src/components/storage-view.tsx index 36d4af3b..4eb7c0e6 100644 --- a/packages/ui/src/components/storage-view.tsx +++ b/packages/ui/src/components/storage-view.tsx @@ -8,7 +8,8 @@ import { clockTime } from "../storage-labels.ts"; import { AppAvatar } from "./app-avatar"; import { BrowseView } from "./storage-browse"; import { ChangesView } from "./storage-changes"; -import { ConfirmButton, StorageEmpty } from "./storage-parts"; +import { ConfirmButton } from "./confirm-button"; +import { StorageEmpty } from "./storage-parts"; import { Alert, AlertDescription } from "./ui/alert"; import { Button } from "./ui/button"; import { ButtonGroup } from "./ui/button-group"; diff --git a/packages/ui/test/app-intelligence-layout.test.ts b/packages/ui/test/app-intelligence-layout.test.ts index 87df4238..2e0047cc 100644 --- a/packages/ui/test/app-intelligence-layout.test.ts +++ b/packages/ui/test/app-intelligence-layout.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { AppIntelSnapshot, IntelEdge } from "@mobile-dev/shared/app-intelligence.ts"; -import { FLOW_METRICS, boundariesIn, boundaryOf, buildFlow, clampName, depthHue, depthTint, ellipsize, flowLink, groupKey, heat, heatT, labelOf, maxReach, openByDefault, pathOf, placeFlow, platformsFor } from "../src/app-intelligence-layout.ts"; +import { FLOW_METRICS, boundariesIn, boundaryOf, buildFlow, clampName, depthHue, depthTint, ellipsize, flowLink, groupKey, labelOf, openByDefault, pathOf, placeFlow, platformsFor } from "../src/app-intelligence-layout.ts"; function snapshotOf(edges: IntelEdge[], files: Record, packages: Record = {}): AppIntelSnapshot { const indexes: AppIntelSnapshot["indexes"] = { screens: Object.create(null), incoming: Object.create(null), outgoing: Object.create(null) }; @@ -114,49 +114,41 @@ test("a boundary past its cap folds the rest into an overflow its key can open", assert.equal(all?.hidden, 0); }); -test("placeFlow lays a hop out as a column or as a row, without overlapping its cards", () => { - const flow = buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) }); - for (const orientation of ["columns", "tree"] as const) { - const placed = placeFlow(flow, orientation); - assert.equal(placed.cards.length, 8, `${orientation} places every drawn row`); - assert.equal(placed.headers.length, 5, `${orientation} draws one header per boundary, and none for the root`); - assert.ok(placed.cards.every(card => placed.rectOf.get(card.ref) === card.rect)); - for (let a = 0; a < placed.cards.length; a++) for (let b = a + 1; b < placed.cards.length; b++) { - const first = placed.cards[a].rect, second = placed.cards[b].rect; - const overlaps = first.x < second.x + second.width - 0.001 && second.x < first.x + first.width - 0.001 - && first.y < second.y + second.height - 0.001 && second.y < first.y + first.height - 0.001; - assert.ok(!overlaps, `${orientation}: ${placed.cards[a].ref} overlaps ${placed.cards[b].ref}`); - } - for (const card of placed.cards) { - assert.ok(card.rect.x >= 0 && card.rect.y >= 0 && card.rect.x + card.rect.width <= placed.width + 0.001 && card.rect.y + card.rect.height <= placed.height + 0.001, `${orientation} keeps every card inside the board`); - } +test("placeFlow stacks a hop into a column without overlapping its cards", () => { + const placed = placeFlow(buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) })); + assert.equal(placed.cards.length, 8, "it places every drawn row"); + assert.equal(placed.headers.length, 5, "one header per boundary, and none for the root"); + assert.ok(placed.cards.every(card => placed.rectOf.get(card.ref) === card.rect)); + for (let a = 0; a < placed.cards.length; a++) for (let b = a + 1; b < placed.cards.length; b++) { + const first = placed.cards[a].rect, second = placed.cards[b].rect; + const overlaps = first.x < second.x + second.width - 0.001 && second.x < first.x + first.width - 0.001 + && first.y < second.y + second.height - 0.001 && second.y < first.y + first.height - 0.001; + assert.ok(!overlaps, `${placed.cards[a].ref} overlaps ${placed.cards[b].ref}`); + } + for (const card of placed.cards) { + assert.ok(card.rect.x >= 0 && card.rect.y >= 0 && card.rect.x + card.rect.width <= placed.width + 0.001 && card.rect.y + card.rect.height <= placed.height + 0.001, "every card stays inside the board"); } }); -test("columns advance along x and the tree advances down y", () => { +test("each hop steps one column along, anchored so a fold moves nothing before it", () => { const flow = buildFlow(groupedSnapshot(), "screen:home", { toggled: openTo(3) }); + const columns = placeFlow(flow); const depthOf = (placed: ReturnType, depth: number) => placed.cards.filter(card => card.depth === depth); - const columns = placeFlow(flow, "columns"); // Rows under a header are indented past it, which the root card has no header to sit under. assert.equal(columns.headers[0].rect.x - depthOf(columns, 0)[0].rect.x, FLOW_METRICS.column); assert.equal(depthOf(columns, 1)[0].rect.x - depthOf(columns, 0)[0].rect.x, FLOW_METRICS.column + FLOW_METRICS.indent); assert.equal(depthOf(columns, 2).every(card => card.rect.x === depthOf(columns, 1)[0].rect.x + FLOW_METRICS.column), true); - // Anchored to the start, not centred: folding one boundary must not move any other level. assert.equal(depthOf(columns, 0)[0].rect.y, FLOW_METRICS.pad); - const folded = placeFlow(buildFlow(groupedSnapshot(), "screen:home"), "columns"); + const folded = placeFlow(buildFlow(groupedSnapshot(), "screen:home")); assert.deepEqual(depthOf(folded, 0)[0].rect, depthOf(columns, 0)[0].rect, "the root sits where it sat before the fold"); assert.deepEqual(depthOf(folded, 1)[0].rect, depthOf(columns, 1)[0].rect); - const tree = placeFlow(flow, "tree"); - assert.ok(depthOf(tree, 2)[0].rect.y > depthOf(tree, 1)[0].rect.y, "each hop sits below the one before it"); - assert.equal(new Set(depthOf(tree, 2).slice(0, 2).map(card => card.rect.y)).size, 1, "rows in one hop share a baseline"); - assert.ok(tree.height > columns.height, "the tree is the taller reading of the same graph"); }); test("a folded boundary still records what feeds it, and where its header landed", () => { const folded = buildFlow(groupedSnapshot(), "screen:home"); const components = folded.levels[2].find(group => group.boundary === "component"); assert.deepEqual(components?.sources, ["file:src/screens/home.tsx"], "the connector has somewhere to come from"); - const placed = placeFlow(folded, "columns"); + const placed = placeFlow(folded); assert.ok(placed.headerRectOf.get(groupKey(2, "component")), "and somewhere to land"); assert.deepEqual(placed.headerRectOf.get(groupKey(2, "component")), placed.headers.find(header => header.key === groupKey(2, "component"))?.rect); // Sources are the cards above, so a boundary fed by two files records both of them once each. @@ -164,33 +156,9 @@ test("a folded boundary still records what feeds it, and where its header landed assert.deepEqual(deep.levels[3].find(group => group.boundary === "util")?.sources, ["file:src/components/Button.tsx"]); }); -test("a link bends along the axis its orientation reads in", () => { +test("a link leaves the right edge and lands on the left", () => { const from = { x: 0, y: 0, width: 100, height: 40 }, to = { x: 200, y: 80, width: 100, height: 40 }; - assert.ok(flowLink(from, to, "columns").startsWith("M100 20"), "columns leave the right edge"); - assert.ok(flowLink(from, to, "tree").startsWith("M50 40"), "the tree leaves the bottom edge"); -}); - -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"); + assert.equal(flowLink(from, to), "M100 20 C145 20 155 100 200 100"); }); test("boundaryOf reads a role off the path", () => { diff --git a/packages/ui/test/app-intelligence-view.test.ts b/packages/ui/test/app-intelligence-view.test.ts index b4259f46..b57489a3 100644 --- a/packages/ui/test/app-intelligence-view.test.ts +++ b/packages/ui/test/app-intelligence-view.test.ts @@ -46,6 +46,11 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a const globals = { window: dom.window, document: dom.window.document, navigator: dom.window.navigator, HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, Element: dom.window.Element, Event: dom.window.Event, + // The confirmation popover is a Radix layer: it walks the tree and dispatches its own events, + // and every constructor it touches has to be this window's, not Node's. + ...Object.fromEntries(["CustomEvent", "KeyboardEvent", "MouseEvent", "PointerEvent", "FocusEvent", "DocumentFragment", + "NodeFilter", "DOMRect", "HTMLButtonElement", "HTMLInputElement", "HTMLSelectElement", "HTMLTextAreaElement", "SVGElement", + ].map(name => [name, (dom.window as unknown as Record)[name]])), getComputedStyle: dom.window.getComputedStyle.bind(dom.window), requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), @@ -89,6 +94,7 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a calls.push({ name, arguments: args }); if (name === "mobile_app_intel_snapshot") return { content: [], structuredContent: { projectPath: "/tmp/social-app", snapshot, recents } }; if (name === "mobile_select_app_intel_project") return { content: [], structuredContent: { projectPath: args?.projectPath, snapshot, recents } }; + if (name === "mobile_clear_app_intel") return { content: [], structuredContent: { projectPath: args?.projectPath, snapshot: null, recents } }; return { content: [], structuredContent: {} }; } }; const panel = new AppIntelligencePanel(app as unknown as App); @@ -104,16 +110,15 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a const groups = () => [...query("[data-flow-group]")].map(node => node.getAttribute("data-flow-group")); assert.ok(dom.window.document.getElementById("app-intelligence-panel"), "the panel keeps the id the package smoke test asserts"); - // The picker is a dropdown over the remembered projects, so switching needs no folder picker. - const picker = dom.window.document.querySelector("[data-project-picker]") as HTMLSelectElement; - assert.equal(picker.tagName, "SELECT"); - assert.deepEqual([...picker.options].map(option => option.textContent), ["social-app", "other-app", "Choose another project…"]); - await act(async () => { - picker.value = "/tmp/other-app"; - picker.dispatchEvent(new dom.window.Event("change", { bubbles: true })); - }); + // The picker is the workspace's own Select, showing the project by folder name. Its menu is a + // popup the panel opens, so the switch itself is driven through the panel the trigger calls. + const picker = () => dom.window.document.querySelector("[data-project-picker]") as HTMLElement; + assert.equal(picker().getAttribute("data-slot"), "select-trigger"); + assert.match(picker().textContent ?? "", /social-app/); + await act(async () => { await panel.selectProject("/tmp/other-app"); }); assert.deepEqual(calls.at(-1), { name: "mobile_select_app_intel_project", arguments: { projectPath: "/tmp/other-app" } }); assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app"); + assert.match(picker().textContent ?? "", /other-app/, "and the trigger follows the choice"); // The Flow is the only view now: the rail lists entry points from the moment a snapshot loads. const rail = dom.window.document.querySelector("aside .overflow-auto") as HTMLElement; @@ -164,15 +169,6 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a await act(async () => { panel.showAllIn("2:component"); }); assert.deepEqual(panel.getSnapshot().expanded, ["2:component"]); - // The orientation toggle re-reads the same graph downwards instead of across. - const svg = () => dom.window.document.querySelector("[data-flow-orientation]") as SVGElement; - assert.equal(svg().getAttribute("data-flow-orientation"), "columns"); - const before = refs(); - await act(async () => { (dom.window.document.querySelector('[data-orientation="tree"]') as HTMLButtonElement).click(); }); - assert.equal(svg().getAttribute("data-flow-orientation"), "tree"); - assert.deepEqual(refs(), before, "the toggle changes the reading direction, not the graph"); - assert.equal(panel.getSnapshot().orientation, "tree"); - // The filter is the entry-point rail's filter: unticking a boundary takes those entries out of // the rail without touching the traced graph. const chips = () => [...query("[data-flow-filter] [data-boundary]")].map(chip => chip.getAttribute("data-boundary")); @@ -235,4 +231,19 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a assert.equal(refs()[0], "screen:home"); assert.deepEqual(panel.getSnapshot().toggled, [], "the folds belonged to the old screen"); assert.deepEqual(panel.getSnapshot().expanded, []); + + // Clear asks first, then throws the saved graph away but keeps the project selected. + const clear = () => dom.window.document.getElementById("app-intel-clear") as HTMLButtonElement | null; + assert.ok(clear(), "Clear sits beside Regenerate while there is a graph"); + const before = calls.length; + await act(async () => { clear()?.click(); }); + assert.equal(calls.length, before, "pressing it only opens the confirmation"); + const confirm = dom.window.document.querySelector("[data-confirm]") as HTMLButtonElement; + assert.ok(confirm, "which offers Cancel and Clear"); + await act(async () => { confirm.click(); }); + assert.deepEqual(calls.at(-1), { name: "mobile_clear_app_intel", arguments: { projectPath: "/tmp/other-app" } }); + assert.equal(panel.getSnapshot().snapshot, null); + assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app", "the project stays selected"); + assert.equal(clear(), null, "and there is nothing left to clear"); + assert.match(dom.window.document.querySelector("header")?.textContent ?? "", /Generate/); }); diff --git a/packages/ui/test/ui-telemetry.test.ts b/packages/ui/test/ui-telemetry.test.ts index 535a64a7..4feb1d6b 100644 --- a/packages/ui/test/ui-telemetry.test.ts +++ b/packages/ui/test/ui-telemetry.test.ts @@ -742,24 +742,24 @@ test("App Intelligence layout measurements carry counts only, never paths", asyn api.setUiSurface("app-intel"); api.recordUiTiming("ui.app_intel.flow_layout", 4); api.setUiGauge("ui.app_intel.flow_nodes", 37); - api.countUiEvent("ui.app_intel.orientation_switch"); api.countUiEvent("ui.app_intel.group_collapse"); api.countUiEvent("ui.app_intel.group_expand"); api.countUiEvent("ui.app_intel.group_show_all"); api.countUiEvent("ui.app_intel.boundary_highlight"); api.countUiEvent("ui.app_intel.project_switch"); + api.countUiEvent("ui.app_intel.clear"); api.countUiEvent("ui.app_intel.boundary_filter"); await api.stopUiTelemetry(); const encoded = bodies.join("\n"); contains(encoded, "ui.app_intel.flow_layout.mean"); contains(encoded, "ui.app_intel.flow_nodes"); - contains(encoded, "ui.app_intel.orientation_switch"); contains(encoded, "ui.app_intel.group_collapse"); contains(encoded, "ui.app_intel.group_expand"); contains(encoded, "ui.app_intel.group_show_all"); contains(encoded, "ui.app_intel.boundary_highlight"); contains(encoded, "ui.app_intel.project_switch"); + contains(encoded, "ui.app_intel.clear"); contains(encoded, "ui.app_intel.boundary_filter"); contains(encoded, "app-intel"); contains(encoded, "/Users/", false); diff --git a/tooling/hosts/hosts.spec.ts b/tooling/hosts/hosts.spec.ts index d9901ae8..99556935 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", "App Intelligence", "TraceSift", "Logs"]) { + for (const label of ["Performance", "Storage", "App Intel", "TraceSift", "Logs"]) { await view.locator("#tool-select").click(); await view.getByRole("option", { name: label }).click(); await expect(view.locator("#tool-select")).toContainText(label); diff --git a/tooling/hosts/visual.spec.ts b/tooling/hosts/visual.spec.ts index 970658c4..352f2955 100644 --- a/tooling/hosts/visual.spec.ts +++ b/tooling/hosts/visual.spec.ts @@ -8,7 +8,7 @@ import type { Theme } from "./profiles.ts"; // match CI: refresh them with `npm run test:visual:docker -- --update-snapshots`, then review the diff. const themes: Theme[] = ["light", "dark"]; -const tools = [["performance", "Performance"], ["storage", "Storage"], ["app-intel", "App Intelligence"], ["tracesift", "TraceSift"]] as const; +const tools = [["performance", "Performance"], ["storage", "Storage"], ["app-intel", "App Intel"], ["tracesift", "TraceSift"]] as const; for (const theme of themes) test.describe(`${theme}`, { tag: "@visual" }, () => { test(`workspace`, async ({ openView }) => { From 07bc213ecb36b8bdf63535d48620933adbe9e9e5 Mon Sep 17 00:00:00 2001 From: Hur Ali Date: Thu, 5 Nov 2026 15:47:02 +0500 Subject: [PATCH 5/9] fix: agent chat wiring --- .../test/workspace-opening.test.ts | 76 +++++++++++++++++++ packages/core/src/app-intelligence/tools.ts | 4 +- packages/core/src/panel-tools.ts | 4 +- packages/mcp-app/src/workspace-app.tsx | 1 + packages/shared/src/panel-target.ts | 3 +- 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/codex-plugin/test/workspace-opening.test.ts b/apps/codex-plugin/test/workspace-opening.test.ts index abd3e179..cdb8398e 100644 --- a/apps/codex-plugin/test/workspace-opening.test.ts +++ b/apps/codex-plugin/test/workspace-opening.test.ts @@ -106,3 +106,79 @@ for (const notification of ["input", "result"] as const) test(`workspace applies const unexpected = calls.filter(name => name.endsWith("_stream_session") || name.startsWith("mobile_boot")); assert.deepEqual(unexpected, []); }); + +test("mobile_open_app_intel's appIntelScreen selects that screen in the workspace", async t => { + const dom = new JSDOM('
', { + pretendToBeVisual: true, runScripts: "outside-only", url: "https://mobile-dev.test/", + }); + const window = dom.window; + window.Math.random = () => 0; + window.console.debug = () => {}; + Object.defineProperty(window.performance, "getEntriesByType", { value: () => [] }); + Object.defineProperty(window.performance, "getEntries", { value: () => [] }); + window.fetch = async () => new Response("", { status: 200 }); + window.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; + window.HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} }); + Object.defineProperty(window.HTMLElement.prototype, "clientWidth", { get: () => 400 }); + Object.defineProperty(window.HTMLElement.prototype, "clientHeight", { get: () => 800 }); + const snapshot = { + formatVersion: 1, + meta: { projectId: "p", generation: "g", analyzedAt: "2026-11-04T00:00:00Z", analyzerVersion: "t", sourceFingerprint: "f" }, + files: { "app/index.tsx": { fingerprint: "x", kind: "source", reach: 1 }, "app/profile.tsx": { fingerprint: "x", kind: "source", reach: 1 } }, + packages: {}, + screens: { + home: { title: "Home", route: "/", source: "app/index.tsx", layouts: [] }, + profile: { title: "Profile", route: "/profile", source: "app/profile.tsx", layouts: [] }, + }, + edges: [ + { from: "screen:home", to: "file:app/index.tsx", kind: "source", platforms: ["ios", "android"] }, + { from: "screen:profile", to: "file:app/profile.tsx", kind: "source", platforms: ["ios", "android"] }, + ], + diagnostics: [], + indexes: { screens: { home: ["0"], profile: ["1"] }, incoming: { "file:app/index.tsx": [0], "file:app/profile.tsx": [1] }, outgoing: { "screen:home": [0], "screen:profile": [1] } }, + }; + let tornDown = false; + function send(message: object) { + const event = new window.MessageEvent("message", { data: { jsonrpc: "2.0", ...message }, source: window }); + window.dispatchEvent(event); + } + function result(id: number | string, value: object) { send({ id, result: value }); } + let initialized = false; + window.postMessage = message => { + if (message.method === "ui/initialize") { + result(message.id, { protocolVersion: "2026-01-26", hostInfo: { name: "test", version: "1" }, hostCapabilities: { serverTools: {}, serverResources: {} }, hostContext: {} }); + } else if (message.method === "ui/notifications/initialized") { + initialized = true; + } else if (message.method === "tools/call") { + const name = message.params.name; + if (name === "mobile_app_intel_snapshot") result(message.id, { content: [], structuredContent: { projectPath: "/app", snapshot, recents: ["/app"] } }); + else if (name === "mobile_list_simulators") result(message.id, { content: [], structuredContent: { connected: true, managed: false, baseUrl: "", devices: [] } }); + else result(message.id, { content: [] }); + } else if (message.id === "teardown") { + tornDown = true; + } else if (message.id !== undefined) { + result(message.id, {}); + } + }; + async function until(condition: () => boolean) { + for (let attempt = 0; attempt < 100; attempt++) { + if (condition()) return; + await setTimeout(5); + } + assert.fail("The workspace did not reach the expected host state."); + } + t.after(async () => { + send({ id: "teardown", method: "ui/resource-teardown", params: {} }); + await until(() => tornDown); + window.close(); + }); + window.eval(built.outputFiles[0].text); + await until(() => initialized); + send({ method: "ui/notifications/tool-result", params: { content: [], structuredContent: { panelTarget: { workspaceTool: "app-intel", appIntelScreen: "profile" } } } }); + await until(() => window.document.querySelector('[data-entry="profile"]') !== null); + await setTimeout(10); + const selected = window.document.querySelector('[data-entry="profile"]'); + assert.equal(selected?.getAttribute("aria-current"), "true"); + const home = window.document.querySelector('[data-entry="home"]'); + assert.equal(home?.getAttribute("aria-current"), "false"); +}); diff --git a/packages/core/src/app-intelligence/tools.ts b/packages/core/src/app-intelligence/tools.ts index 656b974b..43a40e4f 100644 --- a/packages/core/src/app-intelligence/tools.ts +++ b/packages/core/src/app-intelligence/tools.ts @@ -16,7 +16,7 @@ const failure = (error: unknown) => ({ isError: true as const, content: [{ type: 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.", + description: "Analyze a React Native project's Expo Router and React Navigation screens plus its static import graph for iOS and Android. App Intelligence discovers screens itself; App Flow does not need to run first. Pass projectPath to override the folder remembered by the panel. When the user asked to create or see the app's map, follow this with mobile_open_app_intel to render it in chat.", inputSchema: generateAppIntelInput.shape, annotations: write, }, async (input, context) => { const startedAt = performance.now(); @@ -29,7 +29,7 @@ export function registerAppIntelligenceTools(server: McpServer, service: AppInte 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.", + description: "Query a saved React Native App Intelligence graph. Use operation=screen for a screen's dependencies, file for importers/imports, and search when the exact screen ID or project-relative file is unknown. direction=incoming finds consumers; outgoing finds dependencies. Read the compact summary first and paginate only when nextOffset is present. Pass projectPath to override the folder remembered by the panel. When the user asked about a screen by name and wants it shown, not just answered, search here for its ID, then call mobile_open_app_intel with that screen ID to render its dependency graph in chat.", inputSchema: appIntelQueryInput.shape, annotations: read, }, async raw => { const startedAt = performance.now(); diff --git a/packages/core/src/panel-tools.ts b/packages/core/src/panel-tools.ts index bf458912..f4bcd552 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. 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."; +const INSTRUCTIONS = "For mobile app development, open mobile_open_simulator before the first device launch, or reuse the open view. Use selected device IDs. Control apps with the device_* tools when the server has them. Use mobile_generate_app_intel to create the app's screen/dependency map, then mobile_open_app_intel to show it in chat. For a specific screen's dependencies, look its ID up with mobile_app_intel and pass it as mobile_open_app_intel's appIntelScreen to render that screen's graph; use mobile_app_intel alone, without opening the panel, for a file's importers/imports or when the user wants the answer as text, not a view. When the user asks to see their devices, open or reuse the view, whose device menu lists them. If a task has ambiguous device targets, ask the user with mobile_choose_devices when it is available, otherwise in chat; proceed only after they choose. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate. See the Mobile Dev skill, when installed, for device control, logs, profiles, App Intelligence, and performance workflows."; /** Mobile Dev's own device views, which a host adds to the server it runs (see createPanel). */ export type Panel = { @@ -258,7 +258,7 @@ function registerPanel(server: McpServer, { android, cpu, fps, drivers, page, sh }); 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.", + description: "Open App Intelligence when the user asks to see or visualize the selected React Native app's screen and dependency graph, or a specific screen's dependencies. Pass appIntelScreen with a screen ID from mobile_app_intel (use operation=search first if only given a name) to focus the graph on that screen. This does not launch a device or generate a new snapshot.", resourceUri: WORKSPACE_URI, entrypoint: "global", defaults: { workspaceTool: "app-intel" }, startBackend: false, }); diff --git a/packages/mcp-app/src/workspace-app.tsx b/packages/mcp-app/src/workspace-app.tsx index 10d69cad..70cc5de4 100644 --- a/packages/mcp-app/src/workspace-app.tsx +++ b/packages/mcp-app/src/workspace-app.tsx @@ -115,6 +115,7 @@ export function startWorkspaceApp(options: WorkspaceOptions = {}) { return; } if (target.data.workspaceTool) { automaticTool = target.data.workspaceTool; automaticToolRevision++; renderWorkspace(); } + if (target.data.workspaceTool === "app-intel" && target.data.appIntelScreen) panels["app-intel"].selectScreen(target.data.appIntelScreen); void devices.open(target.data).catch(connectionFailed); } app.ontoolinput = input => { diff --git a/packages/shared/src/panel-target.ts b/packages/shared/src/panel-target.ts index bbf2452a..fb08cff5 100644 --- a/packages/shared/src/panel-target.ts +++ b/packages/shared/src/panel-target.ts @@ -4,8 +4,9 @@ const platform = z.enum(["ios", "android"]).optional().describe("The task's esta const kind = z.enum(["simulator", "emulator", "physical"]).optional().describe("The requested device kind. Omit when undecided."); const deviceId = z.string().min(1).max(256).regex(/^[A-Za-z0-9_.:\[\]%-]+$/).optional().describe("An exact device ID from device discovery. Omit to select a matching device automatically."); const workspaceTool = z.enum(["logs", "performance", "storage", "app-intel", "tracesift"]).optional().describe("A workspace tool to open. Omit for the device view."); +const appIntelScreen = z.string().min(1).max(512).optional().describe("An App Intelligence screen ID to focus when workspaceTool is app-intel, from mobile_app_intel's search or screen result. Omit to open on whatever screen the panel last showed."); -export const panelTargetSchema = z.object({ platform, kind, deviceId, workspaceTool }).strict().superRefine((target, context) => { +export const panelTargetSchema = z.object({ platform, kind, deviceId, workspaceTool, appIntelScreen }).strict().superRefine((target, context) => { if (target.platform === "ios" && target.kind === "emulator" || target.platform === "android" && target.kind === "simulator") { context.addIssue({ code: "custom", path: ["kind"], message: "Use simulator for iOS or emulator for Android." }); } From 91b1d06d4cde6402b8f4ea3bf321bf872ffe7dad Mon Sep 17 00:00:00 2001 From: Hur Ali Date: Thu, 5 Nov 2026 16:39:54 +0500 Subject: [PATCH 6/9] fix: bugs --- .../test/workspace-opening.test.ts | 3 + packages/ui/src/app-intelligence-panel.ts | 10 +- .../src/components/app-intelligence-view.tsx | 117 ++++++++++++------ .../ui/test/app-intelligence-view.test.ts | 103 +++++++++++++-- .../codex/workspace-app-intel-dark.png | Bin 29545 -> 25714 bytes .../codex/workspace-app-intel-light.png | Bin 28834 -> 25268 bytes 6 files changed, 179 insertions(+), 54 deletions(-) diff --git a/apps/codex-plugin/test/workspace-opening.test.ts b/apps/codex-plugin/test/workspace-opening.test.ts index cdb8398e..6b14cc12 100644 --- a/apps/codex-plugin/test/workspace-opening.test.ts +++ b/apps/codex-plugin/test/workspace-opening.test.ts @@ -175,6 +175,9 @@ test("mobile_open_app_intel's appIntelScreen selects that screen in the workspac window.eval(built.outputFiles[0].text); await until(() => initialized); send({ method: "ui/notifications/tool-result", params: { content: [], structuredContent: { panelTarget: { workspaceTool: "app-intel", appIntelScreen: "profile" } } } }); + // The entry-point rail starts collapsed; open it to see the selected screen. + await until(() => window.document.querySelector('button[aria-controls="app-intel-entries"]') !== null); + (window.document.querySelector('button[aria-controls="app-intel-entries"]') as HTMLButtonElement).click(); await until(() => window.document.querySelector('[data-entry="profile"]') !== null); await setTimeout(10); const selected = window.document.querySelector('[data-entry="profile"]'); diff --git a/packages/ui/src/app-intelligence-panel.ts b/packages/ui/src/app-intelligence-panel.ts index fa91b6e2..e68379a8 100644 --- a/packages/ui/src/app-intelligence-panel.ts +++ b/packages/ui/src/app-intelligence-panel.ts @@ -34,8 +34,10 @@ export class AppIntelligencePanel { 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(); } + // A panel that already has a cached graph reloads quietly: flipping to busy:"loading" on every + // reopen disabled the controls and reset nothing useful, since the cached graph is still valid. + setAvailable(available: boolean) { this.update({ available }); if (available && this.state.open) void this.load(undefined, { silent: Boolean(this.state.snapshot) }); } + show() { this.update({ open: true }); if (this.state.available) void this.load(undefined, { silent: Boolean(this.state.snapshot) }); } hide() { this.update({ open: false }); } dispose() { this.listeners.clear(); } // A new root means new hops, so the folds from the old one would land on unrelated boundaries. @@ -66,9 +68,9 @@ export class AppIntelligencePanel { this.setBoundaries(next.length === available.length && available.every(entry => next.includes(entry)) ? null : next); } - async load(projectPath?: string) { + async load(projectPath?: string, { silent = false }: { silent?: boolean } = {}) { if (!this.state.available || this.state.busy === "generating") return; - this.update({ busy: "loading", error: "" }); + this.update(silent ? { error: "" } : { busy: "loading", error: "" }); try { const result = await measureUiOperation("ui.app_intel.load", "ui.app_intel.load_failed", () => this.app.callServerTool({ name: "mobile_app_intel_snapshot", arguments: projectPath ? { projectPath } : {} })); // Even a failed read carries the remembered projects, so the picker still has them to offer. diff --git a/packages/ui/src/components/app-intelligence-view.tsx b/packages/ui/src/components/app-intelligence-view.tsx index 77e5aa70..11155ecf 100644 --- a/packages/ui/src/components/app-intelligence-view.tsx +++ b/packages/ui/src/components/app-intelligence-view.tsx @@ -1,4 +1,4 @@ -import { EraserIcon, FolderOpenIcon, ListFilterIcon, MaximizeIcon, MinusIcon, NetworkIcon, PlusIcon, RefreshCwIcon, SearchIcon, SquareIcon } from "lucide-react"; +import { ChevronRightIcon, EraserIcon, FolderOpenIcon, ListFilterIcon, MaximizeIcon, MinusIcon, NetworkIcon, PanelRightCloseIcon, PanelRightOpenIcon, PlusIcon, RefreshCwIcon, SearchIcon, SquareIcon } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { AppIntelligencePanel } from "../app-intelligence-panel.ts"; import type { AppIntelSnapshot, IntelRef } from "@mobile-dev/shared/app-intelligence.ts"; @@ -11,6 +11,7 @@ import { Button } from "./ui/button.tsx"; import { ConfirmButton } from "./confirm-button.tsx"; import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group.tsx"; import { Select, SelectContent, SelectGroup, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/base-select.tsx"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover.tsx"; const BROWSE = "\u0000browse"; const folderName = (path: string) => path.split("/").at(-1) || path; @@ -277,39 +278,59 @@ function FlowView({ snapshot, start, toggled, expanded, litBoundary, height, onT ; } -/** The Flow's boundary filter: one chip per boundary the app's entry points fall into. */ -function BoundaryFilter({ available, selected, lit, onToggle, onLight, onAll }: { +/** + * The entry-point filter: a search field plus one checkbox per boundary, in a dropdown so the + * list beside it keeps the width for names and paths. + */ +function BoundaryFilter({ available, selected, lit, query, onQueryChange, onToggle, onLight, onAll }: { available: Array<{ boundary: Boundary; count: number }>; selected: ReadonlySet; lit: string | null; + query: string; onQueryChange: (value: string) => void; onToggle: (boundary: Boundary) => void; onLight: (boundary: Boundary) => void; onAll: () => void; }) { - if (!available.length) return null; - const all = available.every(entry => selected.has(entry.boundary)); - return
- - - {available.map(({ boundary, count }) => { - const on = selected.has(boundary); - const highlighted = lit === `*:${boundary}`; - return - {/* The dot lights this boundary's connectors at every hop; the label still filters. */} - - - ; - })} -
; + const all = available.length > 0 && available.every(entry => selected.has(entry.boundary)); + const activeCount = available.filter(entry => selected.has(entry.boundary)).length; + return + + + + + onQueryChange(event.target.value)} /> + {available.length > 0 && <> + +
+ {available.map(({ boundary, count }) => { + const on = selected.has(boundary); + const highlighted = lit === `*:${boundary}`; + return
+ + {/* Lights this boundary's connectors at every hop; the checkbox still controls visibility. */} + +
; + })} +
+ } +
+
; } export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: { panel: AppIntelligencePanel }) { const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot); const [query, setQuery] = useState(""); + const [railOpen, setRailOpen] = useState(false); + const [asideOpen, setAsideOpen] = useState(true); const [hover, setHover] = useState(null); const [flowRef, flowSize, flowBox] = useMeasured(); const snapshot = state.snapshot; @@ -337,7 +358,7 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }:
void panel.selectProject(path)} onBrowse={() => void panel.pickProject()} /> - setQuery(event.target.value)} /> +
{state.busy === "generating" || state.busy === "cancelling" ? : <> @@ -350,20 +371,28 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }:
{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.

+ {/* A reopened panel starts blank and reloads what it remembered: showing the empty states + while that load is in flight would flash the wrong screen before the real one lands. */} + {state.busy === "loading" && !snapshot ?

Loading App Intelligence…

+ : !state.projectPath ?

Map a React Native app

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

: !snapshot ?

No App Intelligence yet

Generate a static iOS + Android screen and dependency graph.

- :
+ :
{snapshot.screens[selected ?? ""]?.title ?? "Choose a screen"} -
- - {Math.round(zoom * 100)}% - - +
+
+ + {Math.round(zoom * 100)}% + + +
+
- panel.litBoundaryTo(`*:${boundary}`)} onAll={() => panel.setBoundaries(null)} />
{flowStart ?

Each hop groups its imports by boundary and opens the first of them — click another to drill in, or its dot to light up its connectors. Drag or scroll to pan, ⌘/Ctrl-scroll or the buttons to zoom, double-click to reset.

- }
} {hover &&
diff --git a/packages/ui/test/app-intelligence-view.test.ts b/packages/ui/test/app-intelligence-view.test.ts index b57489a3..3ed9d5be 100644 --- a/packages/ui/test/app-intelligence-view.test.ts +++ b/packages/ui/test/app-intelligence-view.test.ts @@ -1,4 +1,5 @@ import test from "node:test"; +import type { TestContext } from "node:test"; import assert from "node:assert/strict"; import { build } from "esbuild"; import { JSDOM } from "jsdom"; @@ -40,7 +41,8 @@ function fixture(): AppIntelSnapshot { }; } -test("App Intelligence traces a screen, groups each hop by boundary and folds a group away", async t => { +/** Boots a JSDOM environment and the real AppIntelligenceView bundle, with `app` as its host. */ +async function mount(t: TestContext, app: Pick) { const dom = new JSDOM('
', { pretendToBeVisual: true, url: "http://localhost" }); const previous = new Map(); const globals = { @@ -87,6 +89,14 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a const { act, createElement } = await import("react"); const { createRoot } = await import("react-dom/client"); + const panel = new AppIntelligencePanel(app as unknown as App); + const root = createRoot(dom.window.document.getElementById("root")!); + unmount = async () => { await act(async () => { root.unmount(); }); }; + await act(async () => { root.render(createElement(AppIntelligenceView, { panel })); }); + return { dom, act, panel }; +} + +test("App Intelligence traces a screen, groups each hop by boundary and folds a group away", async t => { const snapshot = fixture(); const calls: Array<{ name: string; arguments?: Record }> = []; const recents = ["/tmp/social-app", "/tmp/other-app"]; @@ -97,10 +107,7 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a if (name === "mobile_clear_app_intel") return { content: [], structuredContent: { projectPath: args?.projectPath, snapshot: null, recents } }; 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 })); }); + const { dom, act, panel } = await mount(t, app); await act(async () => { panel.setAvailable(true); panel.show(); }); await act(async () => { await new Promise(done => setTimeout(done, 0)); }); @@ -120,10 +127,18 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a assert.equal(panel.getSnapshot().projectPath, "/tmp/other-app"); assert.match(picker().textContent ?? "", /other-app/, "and the trigger follows the choice"); - // The Flow is the only view now: the rail lists entry points from the moment a snapshot loads. - const rail = dom.window.document.querySelector("aside .overflow-auto") as HTMLElement; + // The rail lists entry points, but starts collapsed so the Flow gets the width. assert.equal(refs().length, 0, "no screen is selected yet"); - assert.ok(query("[data-flow-filter]").length === 1, "the entry-point filter is there before anything is traced"); + assert.equal(dom.window.document.getElementById("app-intel-entries"), null, "the rail is collapsed by default"); + const railToggle = dom.window.document.querySelector('aside button[aria-controls="app-intel-entries"]') as HTMLButtonElement; + assert.equal(railToggle.getAttribute("aria-expanded"), "false"); + await act(async () => { railToggle.click(); }); + assert.equal(railToggle.getAttribute("aria-expanded"), "true"); + const rail = dom.window.document.querySelector("#app-intel-entries") as HTMLElement; + assert.ok(query("[data-flow-filter-trigger]").length === 1, "the entry-point filter is there before anything is traced"); + // The checkboxes live in the filter's dropdown, closed by default; open it once for the rest. + await act(async () => { (dom.window.document.querySelector("[data-flow-filter-trigger]") as HTMLButtonElement).click(); }); + assert.ok(query("[data-flow-filter]").length === 1, "opening the trigger mounts the dropdown"); const screenButton = [...rail.querySelectorAll("button")].find(button => button.textContent?.includes("Profile")); assert.ok(screenButton, "the rail lists screens"); await act(async () => { screenButton.click(); }); @@ -158,6 +173,7 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a assert.equal(panel.getSnapshot().litBoundary, null, "clicking the pin again dims it again"); assert.deepEqual(litOpacity("2:component"), ["0.2"]); const chipDot = dom.window.document.querySelector('[data-boundary-light="other"]') as HTMLButtonElement; + assert.ok(chipDot, "the highlight dot lives in the open dropdown"); await act(async () => { chipDot.click(); }); assert.equal(panel.getSnapshot().litBoundary, "*:other"); assert.equal(chipDot.getAttribute("aria-pressed"), "true"); @@ -174,11 +190,11 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a const chips = () => [...query("[data-flow-filter] [data-boundary]")].map(chip => chip.getAttribute("data-boundary")); assert.deepEqual(chips(), ["all", "other"], "both fixture entries render plain source files"); assert.deepEqual(railEntries().sort(), ["home", "profile"]); - const other = dom.window.document.querySelector('[data-flow-filter] [data-boundary="other"]') as HTMLButtonElement; + const other = dom.window.document.querySelector('[data-flow-filter] [data-boundary="other"]') as HTMLInputElement; await act(async () => { other.click(); }); assert.deepEqual(railEntries(), [], "unticking the only boundary empties the rail"); assert.ok(refs().length > 0, "the filter is the rail's, so the traced graph still draws in full"); - await act(async () => { (dom.window.document.querySelector('[data-flow-filter] [data-boundary="all"]') as HTMLButtonElement).click(); }); + await act(async () => { (dom.window.document.querySelector('[data-flow-filter] [data-boundary="all"]') as HTMLInputElement).click(); }); assert.deepEqual(railEntries().sort(), ["home", "profile"], "All brings them back"); // Dragging pans the board itself, which is what a scroll-based pan failed to do when the graph @@ -232,6 +248,15 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a assert.deepEqual(panel.getSnapshot().toggled, [], "the folds belonged to the old screen"); assert.deepEqual(panel.getSnapshot().expanded, []); + // The whole right bar can be hidden too, giving the Flow the full width back. + const asideToggle = dom.window.document.querySelector("[data-aside-toggle]") as HTMLButtonElement; + assert.equal(asideToggle.getAttribute("aria-pressed"), "true"); + await act(async () => { asideToggle.click(); }); + assert.equal(dom.window.document.querySelector("aside"), null, "hiding it removes the whole bar, not just its list"); + assert.equal(asideToggle.getAttribute("aria-pressed"), "false"); + await act(async () => { asideToggle.click(); }); + assert.ok(dom.window.document.querySelector("aside"), "and it comes back"); + // Clear asks first, then throws the saved graph away but keeps the project selected. const clear = () => dom.window.document.getElementById("app-intel-clear") as HTMLButtonElement | null; assert.ok(clear(), "Clear sits beside Regenerate while there is a graph"); @@ -247,3 +272,61 @@ test("App Intelligence traces a screen, groups each hop by boundary and folds a assert.equal(clear(), null, "and there is nothing left to clear"); assert.match(dom.window.document.querySelector("header")?.textContent ?? "", /Generate/); }); + +test("reopening the panel shows a loading state instead of flashing the empty project picker", async t => { + const snapshot = fixture(); + // A reopened panel is a fresh instance: it remembers nothing until this call resolves, so a + // real delay is needed to observe the in-between state the way a slow host round trip would. + const app = { async callServerTool({ name }: { name: string }) { + if (name === "mobile_app_intel_snapshot") { + await new Promise(done => setTimeout(done, 20)); + return { content: [], structuredContent: { projectPath: "/tmp/social-app", snapshot, recents: ["/tmp/social-app"] } }; + } + return { content: [], structuredContent: {} }; + } }; + const { dom, act, panel } = await mount(t, app); + const text = () => dom.window.document.getElementById("app-intelligence-panel")?.textContent ?? ""; + + await act(async () => { panel.setAvailable(true); panel.show(); }); + assert.equal(panel.getSnapshot().busy, "loading", "the load just started"); + assert.match(text(), /Loading/, "a neutral loading state shows while a remembered project reloads"); + assert.doesNotMatch(text(), /Map a React Native app/, "not the empty state, as if nothing had ever been chosen"); + + await act(async () => { await new Promise(done => setTimeout(done, 30)); }); + assert.equal(panel.getSnapshot().busy, null); + assert.match(text(), /social-app/, "the remembered project's graph lands once the load resolves"); +}); + +test("re-showing a panel that already has a graph reloads quietly, with no disabled flash", async t => { + const snapshot = fixture(); + let calls = 0; + const app = { async callServerTool({ name }: { name: string }) { + if (name === "mobile_app_intel_snapshot") { + calls++; + await new Promise(done => setTimeout(done, 20)); + return { content: [], structuredContent: { projectPath: "/tmp/social-app", snapshot, recents: ["/tmp/social-app"] } }; + } + return { content: [], structuredContent: {} }; + } }; + const { dom, act, panel } = await mount(t, app); + const generateButton = () => [...dom.window.document.querySelectorAll("header button")].find(button => /Generate|Regenerate/.test(button.textContent ?? "")) as HTMLButtonElement | undefined; + const generate = () => generateButton()?.textContent ?? ""; + const generateDisabled = () => generateButton()?.disabled; + + // The first open has nothing cached, so it is the one case that still shows a loading state. + await act(async () => { panel.setAvailable(true); panel.show(); }); + await act(async () => { await new Promise(done => setTimeout(done, 30)); }); + assert.equal(calls, 1); + assert.match(generate(), /Regenerate/, "a graph is already loaded"); + + // Simulating a tool-switch away and back: the view remounts and calls show() again, but this + // time a graph is already cached, so it must not flash "Loading" or disable the controls. + await act(async () => { panel.hide(); }); + await act(async () => { panel.show(); }); + assert.equal(panel.getSnapshot().busy, null, "no disabled-controls flash while the cached graph is still shown"); + assert.equal(generateDisabled(), false, "Regenerate stays clickable during the quiet reload"); + assert.doesNotMatch(dom.window.document.getElementById("app-intelligence-panel")?.textContent ?? "", /Loading App Intelligence/); + await act(async () => { await new Promise(done => setTimeout(done, 30)); }); + assert.equal(calls, 2, "the graph is still refreshed in the background"); + assert.equal(panel.getSnapshot().busy, null); +}); diff --git a/tooling/hosts/screenshots/codex/workspace-app-intel-dark.png b/tooling/hosts/screenshots/codex/workspace-app-intel-dark.png index acbc44651bcc3d94609c00b1a6918066dd68fd5a..b7e485e7a42b63b178a14b89e90bd33d56a1e2b6 100644 GIT binary patch literal 25714 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 H&ZGYUKnLIR literal 29545 zcmeFZXIN8P*Dj0=bR(bwqErAgb&(xeLr zq4%DU03igD0B5nE^ZYsIyyv|CzU#ZLCx6xqtTpFcV~jcGJ?=S1B3^5#(*4Ey7Znv1 z-HYc++Ei4RfaZlem(K$iJjt+_is~BG3#Dhe{^^)m2Gdbu@Zo73>D-sUf4}}Ia)&-y z`NK``9}|2WzU~RyQ$IB%T8ao$CC)YY@sc1^X(@8Nbav834t1acg4f8*?Eh8s=O>-M z)Ucke@%Q)71z%s!dhq<~w-72r_#T}ffc7syFytifC+BugXp$%}Kq zT&NZT^L>4M9-TF*OmQlF|1_SzqoO`*JP-aauFqO_xSs@{gqAsUeIOswL`-w59`V&n zz-5Qg)qX?{bS0ORd;iRRSN^kJ?!;XG{{8zLmTeW>o?OO56Z#uARwnfp=yP%m{J|ULxGY7vdOs$ls#-$KBaL!m!=t>be(k^S% zQmz_3FlQaceAWZ%{dI1YLo%7%7{mI2u-V9~k-o9Ly^Tg!8JDo&5AU8gjHZi)KQ<8K zG4+5yM6-*&XI=uf3W&kSDm$B_n&Q^^JoWlJn(24Bm0H0ejqteq;jpKBwFm#rfzQhR& zqKZ4(s{XdY)G59*{XP0tZd{`eaSF#*kC=Jvre}b^xl14B_B8ykqNrf+SH29>*?Sm> zmY5hAB)!L9ymV=DCN!YGArnV1J?!pnrvKLT%j=G&|73?a_@A@dJwgL$FGz72cx%zHre8`va;p^(Io+{_n>Z#n>?$j zd1;7ZXOv7F@75slyQxCQ-2>W*o0MFya^1xGQ*KR@Dm6>uy`j`u20jwh81x}qpi z7A)}z#16c$yr=?}H9Au#UxN-m`^!KPe;7JTuv&BzJC;z$h&T8%Qv`X2u$(;lsCK-! zE(mG)jzU}K+2tL&2p2me4?B&B#paEYzh}I)=F6_0P2Pe>UH2dkp}=jwu#UhUn5qD=ZfuXSUX)G8jezj#S1`ENBmoh>rv;dM{MytFM9W1Pap{YjNI{5PB=B)V<$ z)E_@wGyB+=C;+aT6y9ANJCfRFbNV^*m^X}nO>UJxQJ%2Q>K0OoZCmJ!{po2#+P4q6 zTMkBTttZ~B&TX#W?c9H=Y-AE&>uWh!DYq!4mSLPOHMy{EKV2hw+F+wN^cn;;vJA31 z`WYZRZs-#+AK*IqrTWFo>9wC@tHHD^VI^h{snZh{I`DR@Y~&|JuBAecB$NbSMvFP7I$s#ogZ39kSFa@RJ}WHA zb})IN70@F7b})-;fL`b8uBN<9i4`jOkBK7F1I#eX4M+3Sq4zxMf2(MySb2Y{Eo;gy zsLlD2Gq?1ISKwFQYgQ9NgonI%(R}4B0lu+T!lU?i|KYWgl7cLICkt&y?GK{QCy^!g zP={!tCqnZgrPC1?_ z&8H`1{MrC`RDI@0VPRwa`X*1r)^@2t@eHD31mkEqWVA-V3k!YWFxrI2k7#>x3L895 zl22eY1K#$y+9z72d>Up8rXEY`92v_jY0xe`* zDFV~<8{GK(xWxKyId`hr5C6u^9opzLVvy&fUflZ!@>RYC~kdtd6 zqF`gyE^|oOb!6bz0AR3rBYI^hA0N~)5%5HcJto2}x+AXWiM>6>zQ4A}StDG(V$el= zhC-YcjS3H?K%mKQd{)keW6DYo_4qCNg{9n0ud>F)iPhdj?)}6cF^_LVUoz@S^;rr- zp?$>Ga#ZY@)Ri2^UqPLA`-X&|cabwpPxyHSRmA#@9c!k@IHbi@0$s+#ASOoTTl^NS zHn+|p$k?XrtSs5O_%B~NF7GULM#(7z?e*~D5Hc9QN0&5vUqrbUK|BoX@+&JU1y!$N zsNKpi-t+4s2CM5ysPJ7){MOI(>Q0AQ%rhDl8t z@%|+{W7=^4bT(8Y*K!-3AXT@1Rjg=Wpfq7ZufDcd{LA$C>(RUwr7)VUs}2r}YkQqV zeP&)ph#atiZoXnO$EO=}`t>5GW~j>^_{e#URKz{82aV<5+sM=0F!J<#hW}E;dM3<# z<|a=5A(q6f5E+4MA!eNUv8O7m*4oC};WZUAZw>bId5yPFEhAgjHHOOV`UcMG;c8Lz z!}S+E1V4Dmrst#G#06tk1XaB2q@UjF263HMsx6mXtm2sefOB3 zIvYr0AT3Euu6S$K7X_#H0^8NeID?yHO6Za08&5kq;l&I)6WhNZ9>EDV3PGXvA#I;1RyOTXb{XaTvhMZI43JM1zgf+VDj29vm*!#9TP73NW%BmBW4o;a=_e*QvuSHi=wWFcAM0^S*oTfBoBqhS%fo>Pz8_7l z$&;J=&y3R=+NVw(-lTO}8T|dXH#V}`sLA<4<_+Je;tU!oGv{OCzEr1S#a1@oc%GmM zaY(ZdEc?9zQHwwnIKYZl&l{}mdE;6mN;61=SCo4Qdny_l8b2SO62l6sUtnAl4)Oc< z>#}ngw!|;+8=Ur%{D)ayd0|N`4fF4J4qB>{DA1Z%OWd|Azv-8%9114kk2>+>qCy#- z%TXUuG1^kn`kHNNqtsYp=2CIX&I%U56|ivF>HH}f_D2|a(K{S=76xW7D#%L%&|0CY z9%s4rzx5mIi;s*%IZVb|w8!37I>z4=c**7)+D^zSaurT&sj(WPHSzA+sQ-|&a&&vr zENu_3W+Ge|ygmr$KQTiF9kY~KtVUhj3BS%Av>RpdD)q^u`}bEgVI&<(%S8axP-6#r zdK!px;Lh+Lx425n%1$STf_zWdA?-Skd16ZzAzf3&LM<)o z`R#FY<*P~5WU0oqne5!4P!Ulh54i^-hS8nAbtxRZ>kqo#47}9%vw52j(fDKMsh3xi z&%}Co+kD5)Q_-#IO=I|rMe5vXa&f46gSlwzY>AR;Sd`Yb29Z#{Wsl9v|v=hR06+tA=pPqEZ zuyvX(ovX4Nij9bna-&8JJ4nSR6xJMl#3d5Jb?$^MzV4iUCSqR%>eZ@nTy9f17L{dOJ`lHgQeyt$h^I6mn71_3;NvYyS?I)vI09BVQhXac3puyHw=uuDdEm~P zr-AdIeawTtM4?w#y}hxMhLiVsgpM?~d7dm@s$@MpSdK~oK<0U4Y+i7ly$?op;ba1Ef z_*jF+Lly>^b#oTfYG!s*or0c}qy;#wj>QFMo{@4JUyY$!xCqb3*R9mCG1B7K1dYDr3k2}FDFiqtYH%L1*wbCm zQOlUF|2@=fhu?ZIRN}I+t(Pclj`82xec;5O$(UwP<1zV#d0IGteCC@LWHr2?%IbUX z(;o=0Jgm)AFDv7Z>`6(BuWLQ(C2gso6>rDMU&kM)m4Pw?-q#@fR~w_#qmR@cS3_sX zPLT$Un&pU&b8qq+9L+up@%yyJH#{>|-b7Cr?FYsvAF1BD&dMr%xc);X$dl9+XOmRp z{BJJc^k8MeGUHJeu?~6c;^?>~RVR0XJ3Vm&Kyn5ZCxmQ8cQP{jEFQ|pWuBHfTK;x! zq*r5gYk@5Rj`WT2F&eZQ%55qYpNE{h!Gn5S)vv>&dDxY;ty+ z^rzGwb4(`kqZmFf7U9=@$_$%VhQJh__5QSCjM1F4e(5tHXbY=p_cCD*JYzdr^)`XU z#;qF5PJzqZ`ZA$rWf9OsoS~8*2EQ7Mz6pv=Qy9Lz%m6c8+)mLiBE-sIZz%*dFUj9( z^22dLaNKdSH@LVI9RED*Ja%baYU?Fr*V%4+@$-}z8XMx%mqc)r&AyvaACm&d)`~&~ z{qdrB`5~8oN4B{Lggg+(p+HSmhE~E6(ADiZFU{bSUO^&>4Z-7j7(J}yzu)1@L>IvVafP#wkm)9CsFIJ{r!FaCO_S$Pg@r< z$Y~GDzTnWt+l9PrJe@$Q55*bvuL5QKc&09!3dBqx<3FKrHJ-6V~TTfa^n8ZbeLn5u0@Gtt5xIi^XbJdVuuXp zzice|)-=1QmD2O)zk1?%0i6f*s(((NxB>*`tTA10L;#7<(#K_juM5O!AwVD0k6H8> zH;#sfk5dM3!4%!AVZYRaq(k02#kWs+6fZ9?!_~(RW`nZH$dyS4oa_A2d~?|uU1h2W z=;X2jpxGf1?l~H;FDMk+($d0m)TyXQQ~r&#`8V(SA&G{+K8V1HmaQ)Oy;+}u$T^!L zRj5SAqu-pon)?Urj<-Z#jzvrG)pK&l?R;-InPr4RLOFqZ-Z3@DQZc+12dFLkfn*Q) zlV?k{>sf8}HzPpe=?7SZwb=o;OLspx-IqG(Y(CFdexCa$@NV^alk-n3{O;oAH^4uD zp8l)rA3yx1eY8a_2PmV4Hua&K8?vOjD5*U$Y|{r#_h{}UDP8^8wV&!2|?!ht|k zVpww*JS9Drlq@VP+}z5>9QA~*t6l`gsuv1)+_vZrSm?w08I=#Aq4cMh|6!?aRS-4n zAJs#Ynqt|d9*cF=ziA&#Ng79|Ty7e|W=ETB~9;J-yFYtU8K{ ziuzpZEGS;b6bg@KhO94UVqsx{&9(m7=hofjZW$RF22R;}CzQ}`Y1<_(STNOiW5oE4 zuxD)vcmL9Fu6J=|fs{+KpPZazTK~QU%&@hlGyY1}uFzWBa(CS68VLC)BkTDKCG`Bs zK^@?X7*5Nz+A()Dj{%$!V|;)UO=j{&d{zbl4CMn-O-(aaFhc8sqFsuQyO`PMEM;AIc#Dkhy{Vq#oe zT>e?UPKiI>EusZPL|!-8H~DUFZERe_U!|wd5V8CTNT#W=Q67Nq4x@RGP#+hGp@k6h zii(OjdD2E%5P@G$uM>QOL3XE=L_qstX9BKkj?H@FG&B8IVk0brgXq2Rb8)q$V8R$3 zxTLKLwo76RE)(*v{*z<68eIjeS-Y|dnm>Bsu&ylM4)Q!`VN}n}1($6QS&l~)j^ib7 z!r|~t=z)=;VY2^qG=VhRx_gzMFW!pfGYsU>&(6xv<)Krn_-K?N(U-H^Onw&ylR}c_unp;@R zGsqvlN#=F@nV&b5Az^7+ZeCFom6u9P$wVzC^(|*}Y$_OrjJpx}@$5I^aHY zD<1D|$u`ze1}ZN+8`VKV=j*>|#g>MUYm<<8Th#2lMjYjaP`yvTZWGk)?Jqs1)^#!v zE=U}9pOVT>Z(R6k_0V%iY5tcCB8{zqIHn4#+~Yywl|8+h-nt3)02}$8$5%@o1^pan*tg4I7wY@ zK@BI|sw)^(&IXumj)qoXi6E|oGQeh{-hyZo!@)?Z4U8Ae;-*Q3;MfrT^~A98W{4Y6 z6&~ERu9-Z%Wm3yb3Pd>%$af%&2qF?)}9eCQni{i2`s|S3Eb0T z`iXK&MPJ{8{LCOe^Ib&+9Y%Q>Ub(%eJ&mXXpM!_zmR$#b_4ew4&H3m2pxs+z8e*=! zC8Gjsp|zayhjf557v3tVn@d%Ioh&>8cBxZ<{NZ-R%p*?$_Dphp_aKDJSz!T&Zt>&& zSf8fFcq8S>`pK1U4SylfwrnE~w~B*#>QV}}6kJJ|uZP!S@b0rRY+DrWo~Rtu>T6I0Bp;dq1%0*>xX))YPBd?UtAN_M)f7FAE>v+q(@mk;a^} z=C;loLI{p%>F5u;m#??V;P(49)E@KYrAGELyy{j$Qg`O}pO9E?eMHEfV`pO8Z@s`U z(2hYVe;4bp8q;U+>OEewC^B3Uw;(Zh-5s^;Xe zV-mGFwqv1u68*lvl|(V*51bEujRkpNsvKbW(Pb zMbJ`33plfQ8&>MJ(y~4TgL~peHRp%PU7FayGOr~+*zUe2a(Zv4s0;xjM6?=k9eWgP z_C{01aVa3Gyf*L^E)ph6N*{<+<8i|i3=$Y8v^Smj6w$?mY^?YWA6^tRwvaZ8OW}}F zX?}^J;hPtln_Np}6|vOUCskjuCTR2pLvcw7&Q29HLx$xGH__)Fc)o9R8Yryfwzjje z%bj`qT9Ag7Z=PAxOZed(iRHxxP~5200GGi{78i%xvX!?NSU+Su*3akDXbKo2+f1{@ zN{%h`7)fmcw@D5NHuXmKv0}b&GOR_*_`haka}YMEu}u>5^wsYgpsw&843UY9n)TA{ zekJL)l$^FMmz;$WbLx)aVEi`4fO&X5e%qR-|9Pfp&w_CdVL{cT*S9uZFEw@N_HDme zRvC>lV+QQBo_?jn{PoVN6ml`iBa})sJCO*yXm>YSFke+B>q-RyLW9a)N%a zrC_7ZneVqI^gE%1e==VR_w)ygAq;SL3)R3T(B0kbR5R5E05`~5f68(LbPgpj*_6t_ zPWvkSDk}w5Qg~Z#%ByTMWF#`gdL2SGSyu7+<2>`4bh0rsx!j@1!NGAtoFxh`Q{u4D z^#LS`J^B^H=8qY}NoGGBCFj~|>j7QvDp&N$`2HQ6ulS9&(z3kOIbqL3_kqyfo2Osh z?BNV}8T_5!h>#=M;>KMecef zVr(ui?Ck73P)no$VbyLrMRuW`L7{(Oz{S~lrp|4C(*Y4AZIN47HM*F4o?~swhaZGt zx=`0^u7m4eXl3KhAAFMf(qdwBOJ#JB4p%djE+Oh)?AiTip~cPlhYzzw-*}y`x4fw6 zR#)$!`D}VL8Rv4OxCszPu-ehFd@o$`#>8%wHY&Nu2hD~{34(U_|L_41@x4(ctD2aY z%FQ+Vw5!fTovo7R^-+fSLAtb#=T*3lg4>K7GO)c2rg#vOlS9jnpIF7c8pN#$0wG+uckNVMLkWE{3>q|0V-Z^#X<**qcPqCgNfXU zAvE-aegDDWYl`g+w?7Limz7MdU(FPU1!<73iwYELJRhppL%rVLN)6fi5IqyUpGGX1 zDhT(a;T| zKd~2@V$bt*gk>UsOFE6waY_)p7 zhh{9H^f6fP&_Tmb98QmnYJC0T442+xqPdI4LH2oT=^&4~(V7nxTX}kAB??(7pSI0O z&+ygz4qN0R^I0FI;SYnq>cu4vTL%>{Tq?Mt*+^;uE`db^R3(r+v8fiwP79Ai;&!;2%GJv=OD*HO4GP6&k&qaVAc4M=Qzb zwI3b@v{(u3G|J_9y4axjE%wK|Xp53#vV0ZP^qbQ=-xo^z7zu;uxLi>F;lYZ*u+YPIZcT{7h;14U%VF8E9$Q_ELwe4vC|h z3Ix)t_0O})MS-?9IrFLVQWM|s6&#cwmBr4}^57jo#`E-8|9Ls>>00zLRHwt<`JF!~ z+rAUFui#@5GAR!(HrErdl8_T@Vm0ONu)gQU=`>&G>KAoONnX2h?VCz8Pf=k0XeI8r zcQ!1LNmRhuL*t4h+B7J?N|O$;I^Gk;=U3y68xi4M#xoxu0vOlg zcVd>U67y0`4ZN0ON3DsOYsyv*z#wFJ5;KnO7h@9};@<8jHaGf&A4Lr>xjx`~y&j>j zV3z!A_&})Wmg0fn-DV*Mvji$Om1a zaT>3FxBOB2Yn_|??mbX*2IMwbs^9VRPJY6O=;S+7Zn^YcSpWAQaV zxzC+zOv4`aocaxIsuF50hNFJ)_UdV@H6ai41aGZcq3Yc}D%Hx@v;?MqCq-a4Uw z(nQYgDb(ls#zxePIzhr%ll$64PtG;sc?0KaZfMFlZ4N3<|24j!lQWarB&k^Ahb@9M zyhSx`Al6xW8%nA1Uho0o@gz1`?( zbxT_B*0HuHY-f8I%Ru8M36@xLvp*eP*2W#{kHjQd?jO+7xEmOtOx#+;SIn}ymDn=H zD(ax4Y$ix@ueC6eozgeoT;b!E@4Ci4qyU9Y4BCxLg|hN&J4`l?dE?T_d9D*p1v36? zEn*?+Y>v|mskNADdANt$&%7jGX`VrZTWaYM0~mrk)U$LY3v)po@^AT+xNKIla>(Vu zaofjOz;kMmRzh1MJ=VD6`W-BYeFQs{`Cs{?V4ZSrXNrDz^l?R5OA_$eJ%1J9hys3F-LSFIXEC`tjt_FC0SAqR3~ z_T9d%*5C_37DK~&2DrnXvalJ&A}qwmx3x_m^;rA7dW(_F&27V9(H4zuDyes#X}`UE zHJg5a-%y?}z~va*#x*{sWV_NY0jb8KiaPAwS_kl)8PGwB()v_-;amc|R41f)?{;oaHdNsn8&>;A|NJf>zE$EBFyHM$a4L)4 zMK2ap38SGu9awhJk#C4=j^e47LAgr1#_+74K!g%@(Ytq|4^+I7a$M%0*UMz!A+$!&0MoAm-xo!4`ut zPk)ApaZQ@z+eC-@)BnL6w$edYiNRX_*&K_czy1nlGv zT70}0<R?}IuhJt5p)qAUdy{o0R zTfxi(RM4OhzQJYDH}qQWm`&=wt}N@G3)pGB-ZVHh8}OquL+*h@wWWz&>q_yZO~=*3 zHm*b;cqC+H+@VfIBAoY;wH0szDO+DqZ22RiDXj44y9vyU$pG%AvqKfpn{%UDrdyIG z2IJ^$eSyiVuZ@%Vdgsp->nFBjePxlyZ~8vJGXWTQ2ZxQ@VwR3M=HlS_-x(ovG+*}% z7~h-t`tW_XB1$nZambEm8-IOdO~;yBr7Gt8ej*o}^aK3MkpCwC9Gm3nQ;hcLIzz=K*kZ%@ow~z^-e4ng#e3u=li>N?|%Gv_}T4(LCfAX*x!i> z3A29lP(@i)x)+Va>2uSkC&VxBIe5QnVR7eTDJ1A4Ml&FZ@|2X$M9$lLAE@jjou17& z65r90ttdF9@Yp6cyAraT%D^zDCK~Y_(C3Yi0q*1t8JgXK8c7 zrwd(DUDuGkBjQf+7oyeRdfa6?I*z^A@^TS317^;0 z$k84^Ujx|pgwjOMA$=c9+Kq!1n~u#`gm`y@8H5cRh+k6wNbZO~hxghrv$N04&B?Er zv3MzcU~_BM$|*x4Pl2-?Ha0dnYow<9Q4W#X*FsZrHPPd{HNm*kR_b>p`B*)n$!vYJ z(ArgF2w(uZe0F&Wze<;UY{-GM0 z_9kg&jIvVgKFs#;IIBs#ZyN~?KMVdtgX*_p;Hsp|Co=rUwjq4u8l$7HD@)%Judlym z6jv04#(Vw5OzJ55V3nmAzLxM$iQ7|jmm4Q^UzY1Q=dHG?lAqM&siuOCSLMhcq+Bjc zZ0xrY!ys}~QFO{VuHRV1ZOSlz%rhr<@!MUAH{FFbGBiRvK3KmyPCxT)XX77hWMBDR zr0UC5;!#qKrh7+rqpL|{sKHEIbAep?WA)rHm0|h9{vtN%$IE`5nDmNu&dXqT%r!LE zH5uY(ftr=E8mnqU#uUQNjA%kx1LCWpz~W-VvTw0*P0TYZfL?R@Et2xBeu$(SN*FhB zJENz5edZ<*2#br0)FF#Nk}bd-ryPocKq#+A#ps?$X8z_`!pHN5;nC4d-@zyQpSe|Q ztE(RxG!5NuMKGQch-Is_3i#F`2{WH_n1O8|{5Yw*_|K6Z3EDW zinI!gn#FFR7jKi{07C{8)jNtTh9Pnua7z=#u7_lZ3T{S z0MyA!Y50ZK!m08jy~w?4fstgXo}|O{sO-YRiS0Qovmf0l&>N7^oJp(y)Xr{w=bS*o zqGlS$=63Ad+#K+he1|t8>U`Ph%PbroV3)$r?ybDeEb@xyvTc=7n?pN}uI!mjjj>!( zPgjx(W+#KQ%wUt*uc7qX*+JD)o~-ksJr@_VIZ=`i;JBM+gW6Oh zspP#kUidm#GUTw8l@Ge495>>Mal5ACo3tkGS7z0IFtGUZv4`>2(z0&H+7 zG{ELwLZjM8TbIJaVaQ*~-~TyG{Mf;DB<~6!7pcJgjm|%1Vg5TAa$u!;xNN|P7>)Az zCA#+^Gz{3;rn=%{%+6vv3#1u^J0~CJk9*dfbjLT#UgAV*F};4_Z6i5qUzsV!2j(T$Jehv{d4Zv zO3>Ke+WIF4k*a>q$f&ch{|M-Fb zfBS(#0Bn`Y>gfTs6Zh`jQ;C(uWAQ)=k+F$MT3T8s9RQb2Ezfjp5t^Q!o|BVv7dM4~ zl7TeY0ssy3M^jN#E%b>1saU(X;0rW?U@#32>FDTVSXg&g7awr4*~|Uz998|2E0AvZ z?j4l}kR5p#GGNOldz@mIapmgOKaMpa`4@nWf}`G?VN9x=r&MPc`G3E*DwhNKT}v3> zruM}hJU&eh>}z3>+4kldl5bVdr6s|nV~ezA7j5{9ENaF?mT|%`SZgS89%Hp5MtRV10r%iieVLhNkMx{ zC#bCi^#1yso^=Trp!*c@4|vY`m$;mHG}4nlefow%U$}6Aj=CMlSOe14?CYk9+tYPk z>w`cnbvJW$d0EV5s`?5ekNp$Y?BO3{jsvz{etva8=~h93gR}G5n5$=MaSu2ok%UZY z`==-SE?v5mWgFJa|LD=9vtX1Agr?jo(L0o)`l0Y3AoI2*K@3pGeF*g};FXs1(p+y4 zz(9!+hm3DH7jiG5j9ngL$b>oEUG6j!6&0O3Z#eVdB>70vS_PSy|aO8Xq68OcnK&aG(T(!FF+~T+nJL z`JinSNg6AQwA=KSJ~Lwa|4nKCR^Gp_LhsKc=&`f#Y!_GWvAnmpN3F>v7r5WrGM6D~ z`SXJtWSI>xEKXTD0RiVj-t$!N)c*cwHbDN1MaLFEzh`G>4>c6k)e%D&i?@NDyO_DsIJIiD2<_;ME~k94I@ND!6v+$(WloY zPYptW=RNaHINmL2vO+FcL9$0GN3*=`Gy}iA(Hvc4_WO90k+B0;NMx41wkktm_a4m* zw8P^n+aZ8tM6Lpga4*@y`hLjIc_j`1)D}N_;{wNQFEfw_pLm;1u!AR0px-SM;cYax zFZ`tyG5l!WDEa2LA9f#$ZQTr@jSmsFp&#EKnuU^yQB!9Xx&O0E&tyOGOL;lU;Kxt= zJzo}EYwFas6GDWxO7nw=wv%fEdTH}V%Q_BL_J2SEWWTh&zk&>Q)@0I(3ME+TdFhwf z5)XF%8WQXME77Jqkn7cUO)cuH+l;G3hstfpYOR z13oV&D>s1>bLo_6I(V`@Nm<*&-Dk}g4szVxj(?olYmosxICLdu{M^HS4f7i0=$7}( z0MVzm57afNykl88DRh3bZ73 zKi(bi>+Sdh>QsW;eK;C8anQpR9}cc^y`=&C^4X#-HJTkrpbH)yow07UqJYLLTJi?Q zkO<31QAuy96NP(T^_gnlPCjZ6h@Wi(O5Qdgf;jB7SkhM-Tlox-|5tHIDctTyE%Bk% zoZQtCpPZ|pi)glVe}lOT4vVwf+p{f=69Ii0FCAbW=EhUa>r?r{0erl@C=ee$EJ}yH z2>)2eGo$E=#bJBD{rsEHMFDLIYMvu-1H*g&<^nwRpYRF=QLxz40GH*AyqKX?!AFkr z;DzyF-9zJCi^2AkkoH4LKq$soP9#hdUfOL)Y&KX9Z-HumL+>t*>RfeMotEJdHb>ld zY%+A6`Uz28(@`7T3KBNiw!4fIG)<4sc*K(@ILJ7Laeo-|Rq1zvY zZAl3VHU0rM`5XP!?p}*qg$HwU2Zc2s*EWC?lb86+Q^BJH9E-RicmV8MBXvIeLP+O+ ztK3H?5a>=di?h?w5rjs#N3QgP8fGk9?X~n9?Dz~;ML?et63F)X*IR-rV|tx z__DqThD=;Cc3mt5{QFujil3umGa|V6W9^is>(tB}EVHti(Ok|4^~d!$$7b)(nQt)j zYd3hraYMBIzn!OQ6;}nkPQAX5AIiB*D=1k)Dln-CW>6=^6M(&}K^gp#zXud1H}MZQ z4>MK)2?j&AN9aA&c4^sX-2YW2EGZ^6C0Eh3)ohbelqhI=7^vvx-dj1=XU*2jpH{MD z6ucO^)1xLaE+BPWp0`b{Fpuq_O_Hw>8PJJ+Ic?ktdc9vbwpIU&VN-gl4{xx(qR{NV z=cRre-*IbC3eWBc!`!$;Y5)4d(nZUmbay`Q`XaUB;r37hNY*cKP>(vKY-Cb~qAw#| zGTV|Nx3)2_X_b`1ks$*vp?~mtJs)tu1C|#7KSSr`jq{)jv|W1}c``e<_UMT+2)d{f zV7VHVud1r5X;EKW0IOVDXW6(7@|nFxfULbYLR3g;3P!m23kga{ON;UXGHFNOlPS_K zo3r@^ksKui8>#xGHMrm_pAg3?W=1S#uzuvFd`}2Uh$$*g{m3%KsU*;BwPf_D;qMs( z7IA2x^UhBGHKK$Yq{Q?^fF!T6fD(lrav~cRsK1^uKo=C@@F*qYWSeiU{r zk2cc>Nu$vg`7P43U_U?hXBN$t zm>&Q5(eEKLm7SZ{k(1Lv{7s=|xrw?u7jS@y|621~gCXjC*=C55FD53~QY$#46U~#m z`by&P=*TFow@!w~CQlg+35icCHr(}(Y#XZy3PPACS`y5soR<92z zQprxetQ75=wxMbK%BPJQR;8PM4-G_U@|YtoTNI(_HP;kjP|9VdINO4#V_B z?amNh8(R(f17`3OuP-CDMca{Pu518?45Ol^GMz34_tw?$nst_HU??d)F&+xKWO&xt{TCWQW@P&*claU-^IA!r59LBY?xfi#fN(C+l;Z{PY7&+UWOfTGSN z)QLzSxyvlbK~oU!dNMl#aT=UT4c#sh+X2E3!0uR?%vodyQq|XHNQnK|Y<& zfBHW`$mRbA*Zbe(1x%?MKU`8v5_&79P;SEDc>C+po*`rP(H(+9fN zTUlH-AK)?#PV2o%UU_6(bm%1DfvRg zAiNA&i8$FWdf8l3^RQjtJ3{rh>y&o2w$pa~3PF#c6>)vAaY~=8N8F=+hrR-clSNg0 z5b_Y0VV~P#Yw`j}BiV@7+AUxZh`9|G)79St0*nGLKCsQvtGg)<`<|3IIx^*`C6rPa^+H|UdB$Si(a$_a*o~^* z{8}rOyQr?>Ncz6L$pCD^T5wpM=Xtj?d^0@{)ICB^F6gVZlsKlxBx zm_i{(ec$o^Htv-8J42PVcE|B2lH<4HAFt(fDTiI;Gb20kMMf13mR?d< z(w}=DKJFML`pC7}|CNs3ZsQHnA9)eU@pX5mblsvfSm(7UBdLxLcm!pvyE?9w*w>Ax z?)_YHtkPi=BdU7&Fqah>Tqfyeq`btA-J)0mGmHr{;cf z3z;oGgY}o9D*xpm~S_5q4>*@Djuf`AwhyS9(QO&($i|23J)wN3eRQk>Ju30~Tf`B29(n8}IaC?JuVk zJd(dk#pdmRNMTm}jrBoII?;qRg;UJldsaK$aZ2KFWqXarpvyi;4qg3g)&sM9Tlrf%`KE4Db?%MYa-n=+Ng|I+Dehg5+UD{A_o z7=g_Ha%PBQFWmlLza_$0bW&_cJbu>G(9jb~i!|EKCjHRJmmx*7NQQ?<4ob62K((W%I7LK(rv6^a`dgM^T=~Ar!?n~rZe9(A7nxt-=(#zTJ+)U_1us-e zto#~ltu$O4zh?`acG`71sYUd`Gx;(M38Y>Zj#+g#Vc3!a|0`bVo0wyJN5!Qt)#8`R znh$@%NtCesAqbQV?j<&vArC}=C$%O2!(xQKL?tYvB ztJ=#V6G441YvmU{VM07@KZaEws5+$aMHZzA>Z^I__1uh)J6txx0VVVhrlL1>sc1F4 zg@475;nJw*k>mfPy)%nyBJK7*whgweh#<2f0xh5rFpL5c8bOeGOpsZFj4_}>m?JiA zGY=~B7-a~MkVJ%p8Kfm3NPqxIm_q|H6I2Mum=oV~`aS33d~1DI-})~4s%q6*bx~E% z-uv0Vz4yOzubN{dDWbu_VE>3xp)BFx(97elADvn=RZVogASkC)tE-5WEdDCMnEbXg4Dn?TQLZUvjab6fYBF+-k2U9D zR!YyMSik7hLRywkT~selthfY5EYs7Tml>BXR8Jv1K>oj@L3L~7j*Fkw6Jotd6=gl~W&xta^CRM}1S$8wa_~V8AN>N8@<3JfWb< zPQ_BJ=>P+@0RY_vAb;A|1e#Hc#S+5_-H349c1JspbuhTYf>14%E*4K{Dj6?( zmcrOkt-?Nn5n;wd{_rVg`HB(u(@ybvc@EQ)To@a-5o6i(RN3csc|6GwpxlJ#p28;@;p5?w|+(VM}v@Of3pDP zN>26AwoGid|N7f;Yi6A;@@DLf;}7wXAvR(!2+ON#MMtNm$GqR_dc}r-dY1uWtN7tK z?}Md3Qq+Uj)%bXGJAGVig3!a0OGlP-QdhIU-;2+~623fiT5vXIVTErITK2GdP^OC2}vo+O-^2izC z+0FFZPp9QQPAm*#RXiMKuf;cf{SdwVaX@OqVc=krVdrFTOT8*foiJ>$yqfuA2Qc+3`X72!v#LI#h_}|i2%2De!e2{GaD;0 zd3W?We@hpK>bC^uudu^9FjBMZjidGEksNQ-l$(2#<`q?dTusfZ<(sZoe{SfxC?<^~ zs7;Uy6fsqk?BAXq?PNJVQjA`sROAF{CjJf$VbXuR)t8@9d{#Y|}SI z4UvbX7`(x2Rbg`^3H>q$QhYe(WaU`+H6naUAm^s=!;WOy>|kf7!ewuROmBimh9_ui zsO3Qsk=S2$zw8- zh?&p*oh13Iny_p%>0yyVtLnm~NSg2J`1YO-Dh2FlRHrsC-)!@F;3t2;mGCN3&;3(* zc{$ofyR+O^eQkatHlQ%Vj=)Kt*MKWkS%+T&0Pv-|TS6`Hs^+I&vt6sJxRY~P^d$K& zp7H9Yp9^!sI2LB-vOosfJrHni`&;V$FNwv?eovm)BN_*(Ez_#M3h+36&$ix?-;n6_ zx<)U3E}sFN2OoTDoncB_vbRrK0R8+(SNDE%IH#%0xJIBoo=?cF9**C#5tZ|j83$S9 zP^yvyAvCZZxSV%)w?H=*IvcyEOb1CLJD1fhL>ONE&c*bzxQ&T+;v^y#ooi~`Ik@PM zBv^BHU?vmO6n7ivzL>I{2(HB%b`85$@~f?ksC#8rNWV(I-a3m%2p)fi@3>K*_K**a zNzcp67k(rNht)@p3HV-=?b3R4Apw?S0kp z^CgnAKIkU-$O+wK@#7F#A+K4X)c!-_KJM^@wHQ=<)HXHB$Uqw`o%7-%)vW<R4R8I1nBQ?X42Y!HYfi@S-l2iqu#Y3U}xvnK=19evl(7K6f@27 ze5@-jZhkjelw$KZX2kKF?9nyWxt@n*#3rh!nw*?w|0_GqxU$KXmc~Tim|trK0)qp7 zmo>JiYO0*y3Mbl70|sowq~VqUF80qnq+jH|ki_M#476s~O*u0Bs0vM@<-?}Glu;5H z`2nyKslSa|X(FVWzUe@tI+8owY9D5JIt*Tkvc?W(iowqT;>0r(X_ zlU$yfUskQ*q!6i>P;wOqk^!1HomnaIy=>|iuY17?DOmf~hnz6ZFzzcv?by&{!(^1JZa05WU5J3Y8QVO~)!ed7an;H1`7IR!=Z&hM}2AF!6*yf#fzSrDZ)WMQ_*v zdZ0%V5y^Zq8T_?uYUJ~B&0uF;Uo-c5(2b@jBf~GO_pRMlR`G{kK2s3`2Fwg#l*v|4 z`n0nXHG1+%L(P}xoM#h58;$$@5;aZl^|XZHa08{rB2z$rg8%ZL+Cfr5%)NzX)VFTt z3$3XbW_+sYKF1Ma+HKi*>oBB#Gdn7nRVWy2b0>42zBi<; z1k!o(b^ePHD%aYi|HFrdVEQkd{uOP8lg7?ndK)lmo6=+hTNT(jQ;l1{KUz1& zEY#mMae&kC0s~9|-*`TgukNEV96S`hXc}K_KGF-^UECFNakq#DL+r@YRmFra+rThG$` zM3ivYwM%>PlZ#Oqqp7X>zwN?3)S=vA0&=< zr6b#S*XJ`_r$-&^KiZm>F(yYwb>u;shb%o`g*ZJ)3(kQL ztz&vKJ4-ll${oyXCs>c`xul;DYV}HLgNGgZqWBy06mU{-(wi$`=qV5kV!!moMqqdP z`rC+Zl?-T}mX&camK6i{x60N;$`Zw3XYZ3&=W3j1TF?@Yd{(Fq+DRU@`mpsv36><9GQp{qMV?cdxj^40cgS5_7V&g4~hJgR42@n~@{si)U{`@_$w z8FPO8OnOoq;LsXaKTo)@x7zgB;X`Ine_GhcL91QSwbk>?gDys9Tx)k}J1a6`19W|h zy0tA`I1nkq^N#nw>aq-N#tx6c{BGcog|z+KIJAdCEgNjWnL6 zv>L4X;cvQG=xXrBg3#GVfNvj)@WP^%A=^Lk+)fv~%k$3QsR|Sl`M6I+@`VEeSXHGi z8{%|eHLd#QQbjqDcE7RZQ16X}DyNTt2(2BJ+fwCu%Dtm(N3D%$aZ_aeU@Z@JU zX;)U6rCN+=inZ7eR(AFE-nK}SkS-h7%_n6EXE9v+$8B#tcoOj_F)9rsBO|9^w00ft z85Z>~%n8BHdK;>?PJhbR0wD||?V}UubPosn`SD>qMGDH~sw;oJm{ds_V4~5V00~;O ziIR-2FyZz(bim!5#MU~+DQ9p+Cs`b!n~G~JseFiyii3J{rBnX}O`+|M!eUOuz0=d> z*rV=<#Y5;q%(D6ZlWdpbc62ZD+ElX&1#-}v@3J@;A2ryu>^1bb*7w$lE6|TvE6hM1 z?jjVmlyT85bDu{p7{Qw*fxgt(rG=j}WPq}_2@Vt@dDYx4zGLF1 z`*3Zok8!-8h^wjBtw+>s6YO{FN`2t&o`s11GrZM$P6H0`*vMx+afL!cApt4jQY1#> z#i<8iuZ1H&JbqS$!IBEd>Xe-wdOIXk`aX69zm4v~eY#9@I@mhKT~&Wpa$AUac^>N9 zrq(X_+N>x$*Y3qk@4tcH?<)%Hx_~3-UkKShE?}wG$W~S2RPzxlu-e$eGuUoW0rD*wl@Bj9pcje45|h=V7nXjJAv%ks+6(#aMn{BQ;SY#ckDx4D2-W*Y{DKDqK@QpPU9~*ccx{Trn3$~(Y+L)TeHrlQ# z9Q8o_QMks$H+z>UxH|;q{%lDqoQUzIwbH{Ds97y&> zdFtORKnY?0ycHK|`unZ(>hmB8|H8);BW4|RN{q+mLKMQ3T$3*}>Np&(eE$QyK0v{; z8UDEQgFrI9S{3)e7Co5*I@y#CO98jzrr@oH z!1^gIovl%NEG_7WxH3$w|J3($;O?7_5^*%e^Eu12J8^gnkZ z0=)dY_68Cen}o1$F`pM!W1g&XB&)kz)iXj+Prmic=>NdeVEvFA9o)443sym}Qvrd~ zO$&dB(MMbyz3jP?yrlcCrDH=b?k!K;{VB@>aV{_=3>Vlnm!z9%xU5?A2Dk!59gu&M zDwF?}EU~aRbm03~j9Y1)HMXj!`bP=&%5#hMQSipO_Qt42oJ>3BF-W4lRi(n)ihU%|mU_7?h$874j=ntoztRKcY4M)R^ z?}fljgX5#(lAu8VPquxVxI5_%C3WIz0Klab=(i+w*B=kmNqr=UCqmfNlP10}EA0B8 z*fMo_ligfWW@GwoM8qwxFqH4!wf!zV1r>NMjd%J=-T^-@7zPs#s%s?2etx z#XtX_*AO^r1$LDz zRTsT+amvxj$&)!cGV*`~L+BE}L!-I$PzNN@>>$R;biCSKYsV4(+^~W_@5`aLdVk+X zA6hz9BKC*lMi;+D%;xnV_t}!f_h28$>T>l%l>2Jw=#D^5$R}AH09s|>U~jL`nrsR_ zt^!|v-#4o#7(4E;}vIO z#juFDgxqgsWoz+o*J!Y>A6pG3s|t6|GwE$tI9c|?GX{H_f+j$D(mFdQDSyBmL_3S5 zTBrupT}4e?1|Y8=cP&rm){{hrYrLnFz$Fjs%ub1xvzh9SH}#695WJj5^wCSLdkGID zq0FQry>At5pid>fm692Yi~Y8%<`B`5`uzPA=X8+pr^7KiT{q*UV9-cC=8;T4y5vXz zC_pq9^Ik%hge`utjd3_lyhN*X%LlnS^jIii`;UHYlsDgvj++j(@IQL{7-?*23ANul zhzdd1dwMj|xU*sJ*zpdpj?L`7Uf~L>4hIC{@pszQ+8e;w&t% zxqhAF3?EHb5%&bcs7Nj7E1S}*p^W?AQHNwV{)+*7DEnx)oy@k+c>;7qKC^>WAy5}# zV!wCT$^6EG$xkRl3g`$?ysu$b*+|}_CQveQz*PD*qj2oTRC!QZ4sxH@kDt2NcIfK9 z2y-ohUiCzN=iUY@^P^e{o202wqFg8-*dRJn54vVDE39m-#UFkySX0xxV@<` zo4$vc)l_Y7qXeX+|7G#}v6&`g9`1KHlFlirU;52)2eprR5?wcKde9i84R#7!5*l`x zJN<8Fu(9?6;QQS}E6D3B>D(k>nkwt!zlxTb!h~TCa0k!M>S|}8tdNGF`r@K~=BGoW zH%2N`h?6VNMmkPD&Tp_dUpBnbLh36A9A4lWo?|X+vT?$AXXP}0NCcY&Qa7&IZJ@tv zYj2`*>sJaoy|2=5_vaU6-S7aX)j_ybAD{aPQVQ8Yw|glc3kv*fRRaxuSvzlo4BA1> zlq&wDpQwq#DdyJcPW!?*`c#t9hCM9U9V?^>gFIXE+#$YEfo4_9e<@5D*(&>~6(oBF z4sndR<`O@q0Fp8DuLAP5%9z}+Vba`~)L{P!miOyW2P^k@l_%CNFT>T1Y2DuXx!N&t z)F@L|N$d{HYiLL)CDO~yB{Vd$FmSNdi11Qg7SU1Cvi4)x=1L0Cq)4f`2uwEUy&S`=Cc}PC!uDBD zIs3lWwQ@ZLx#~RTA7Qhg$SvfvKdo9w-clOFGH{QriaGb=0#^NS*gH;cL4xbyCoRI< zZTx*OBmy3d801a6m7u}M9omSP=}9P3;@IlX9~5R=IPW=6QZjGs@7tKbBiCb23=Fw> z+GVu7JCuO94xV^%>Hdnp41mH9-(?TPO=&(gbOb`rxq zO9)XxIPZhW%eBY38(#OGCN+jaQoP6WdIS9y<&kNZHlI{|C0KunM5DP=l$X_`j%-K; zLw`xbiJ&^@c|pAnyb*1tI=S>%UaZmuK{XM*x-Lf{u-@F_9A6-?M=Kufz diff --git a/tooling/hosts/screenshots/codex/workspace-app-intel-light.png b/tooling/hosts/screenshots/codex/workspace-app-intel-light.png index a2133166bbf91df91aa14455a81e1c6982f11d71..6421f61f51ddf1ce4e38f670ee38978638c7b5d8 100644 GIT binary patch literal 25268 zcmeFZcT|(xw>FBRqS91URGND$B2pEp0kYk0MMPjLASFOhHXt<=A%PI0BCzROX`B92 z+NSp&5;{s%S|EXhP^E-GfFy*J`*O~AzB|qs-*@l1cib`VKPUf?ezdaKWI1aoOyn6B|@Mni@QzjsAM!@*DdzOf-jagKv)!y4V ze)wNP65FnKe=@E6`bu5YIqCbi^Ce&HZuVPh;LN&*EXP*G>t82VJdlA$V0)w1d_I9D zqk>{)Us$>pRcpqu){nK`j0ijjx%q2$zKY^sW=Xf)uM%b+zmz=|wsPam5eZpk!uOpp z{yo^R0aKqe9=ge#K`LPA{YpoG9%0Dw#6}ntcqc%ED;#$9%#ov)1O(nZI@1n(`0bs* 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 Date: Fri, 6 Nov 2026 15:00:34 +0500 Subject: [PATCH 7/9] feat: ui feedbacks --- .../src/components/app-intelligence-view.tsx | 132 +++++++++++++----- packages/ui/src/style.css | 17 +++ .../ui/test/app-intelligence-view.test.ts | 54 +++++++ .../codex/workspace-app-intel-dark.png | Bin 25714 -> 25920 bytes .../codex/workspace-app-intel-light.png | Bin 25268 -> 25228 bytes 5 files changed, 167 insertions(+), 36 deletions(-) diff --git a/packages/ui/src/components/app-intelligence-view.tsx b/packages/ui/src/components/app-intelligence-view.tsx index 11155ecf..5d59bdac 100644 --- a/packages/ui/src/components/app-intelligence-view.tsx +++ b/packages/ui/src/components/app-intelligence-view.tsx @@ -278,6 +278,36 @@ function FlowView({ snapshot, start, toggled, expanded, litBoundary, height, onT ; } +function BoundaryOptions({ available, selected, lit, onToggle, onLight, onAll }: { + available: Array<{ boundary: Boundary; count: number }>; selected: ReadonlySet; lit: string | null; + onToggle: (boundary: Boundary) => void; onLight?: (boundary: Boundary) => void; onAll: (checked: boolean) => void; +}) { + const all = available.length > 0 && available.every(entry => selected.has(entry.boundary)); + return <> + +

+ {available.map(({ boundary, count }) => { + const on = selected.has(boundary); + const highlighted = lit === `*:${boundary}`; + return
+ + {onLight && } +
; + })} +
+ ; +} + /** * The entry-point filter: a search field plus one checkbox per boundary, in a dropdown so the * list beside it keeps the width for names and paths. @@ -285,7 +315,7 @@ function FlowView({ snapshot, start, toggled, expanded, litBoundary, height, onT function BoundaryFilter({ available, selected, lit, query, onQueryChange, onToggle, onLight, onAll }: { available: Array<{ boundary: Boundary; count: number }>; selected: ReadonlySet; lit: string | null; query: string; onQueryChange: (value: string) => void; - onToggle: (boundary: Boundary) => void; onLight: (boundary: Boundary) => void; onAll: () => void; + onToggle: (boundary: Boundary) => void; onLight: (boundary: Boundary) => void; onAll: (checked: boolean) => void; }) { const all = available.length > 0 && available.every(entry => selected.has(entry.boundary)); const activeCount = available.filter(entry => selected.has(entry.boundary)).length; @@ -297,35 +327,61 @@ function BoundaryFilter({ available, selected, lit, query, onQueryChange, onTogg - onQueryChange(event.target.value)} /> - {available.length > 0 && <> - -
- {available.map(({ boundary, count }) => { - const on = selected.has(boundary); - const highlighted = lit === `*:${boundary}`; - return
- - {/* Lights this boundary's connectors at every hop; the checkbox still controls visibility. */} - -
; - })} -
- } + onQueryChange(event.target.value)} /> + {available.length > 0 &&
}
; } +/** The generated project's home: its entry-point boundaries arranged around the project. */ +function ProjectOverview({ projectName, available, activeBoundary, onSelect }: { + projectName: string; available: Array<{ boundary: Boundary; count: number }>; + activeBoundary: string | null; onSelect: (boundary: Boundary) => void; +}) { + const total = available.reduce((sum, entry) => sum + entry.count, 0); + const centre = { x: 300, y: 210 }; + const points = available.map((entry, index) => { + const angle = -Math.PI / 2 + index * (Math.PI * 2 / Math.max(available.length, 1)); + return { ...entry, x: centre.x + Math.cos(angle) * 220, y: centre.y + Math.sin(angle) * 145 }; + }); + + return
+ +
+
+ + {projectName} + {total} {total === 1 ? "entry point" : "entry points"} +
+
+ {points.map(({ boundary, count, x, y }, index) => { + const active = activeBoundary === boundary; + return
+ +
; + })} + {available.length === 0 &&

No entry points were found in this graph.

} + {available.length > 0 &&

Select an area to browse its entry points

} +
; +} + export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: { panel: AppIntelligencePanel }) { const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot); const [query, setQuery] = useState(""); @@ -374,12 +430,12 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }: {/* A reopened panel starts blank and reloads what it remembered: showing the empty states while that load is in flight would flash the wrong screen before the real one lands. */} {state.busy === "loading" && !snapshot ?

Loading App Intelligence…

- : !state.projectPath ?

Map a React Native app

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

- : !snapshot ?

No App Intelligence yet

Generate a static iOS + Android screen and dependency graph.

+ : !state.projectPath ?

Map a React Native app

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

+ : !snapshot ?

No App Intelligence yet

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

:
- {snapshot.screens[selected ?? ""]?.title ?? "Choose a screen"} + {snapshot.screens[selected ?? ""]?.title ?? "Project overview"}
@@ -394,13 +450,17 @@ export const AppIntelligenceView = memo(function AppIntelligenceView({ panel }:
-
- {flowStart ? panel.toggleGroup(key, open)} onLight={key => panel.litBoundaryTo(key)} onShowAll={key => panel.showAllIn(key)} onHover={setHover} /> - :
Choose an entry point to trace its dependencies.
} -
+ {flowStart ?
panel.toggleGroup(key, open)} onLight={key => panel.litBoundaryTo(key)} onShowAll={key => panel.showAllIn(key)} onHover={setHover} />
+ :
+ { setQuery(""); panel.setBoundaries([boundary]); setAsideOpen(true); setRailOpen(true); }} /> +
}
-

Each hop groups its imports by boundary and opens the first of them — click another to drill in, or its dot to light up its connectors. Drag or scroll to pan, ⌘/Ctrl-scroll or the buttons to zoom, double-click to reset.

+

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

{asideOpen &&