diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..fe6791c --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "agentic-react-vite-playground", + "agentic-react-webpack-playground", + "agentic-react-next-playground", + "agentic-react-nx-module-federation-playground" + ] +} diff --git a/.changeset/custom-tuning-modal-api.md b/.changeset/custom-tuning-modal-api.md new file mode 100644 index 0000000..ad88c47 --- /dev/null +++ b/.changeset/custom-tuning-modal-api.md @@ -0,0 +1,8 @@ +--- +"@agentic-react/core": minor +"@agentic-react/vite": minor +"@agentic-react/webpack": minor +"@agentic-react/next": minor +--- + +Add customizable tuning modal slots, wrapper extensions, and adapter toolkit configuration support. diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 21d4741..f5ac82a 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,7 +1,7 @@ { "mcpServers": { - "user-profile-app": { - "url": "http://localhost:3000/sse" + "vite-react-app": { + "url": "http://127.0.0.1:51423/sse" } } -} \ No newline at end of file +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fa274ed --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm run build + + - name: Create version PR or publish + uses: changesets/action@v1 + with: + version: pnpm run version-packages + publish: pnpm run release + title: "chore: version packages" + commit: "chore: version packages" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 6bbc3b7..b9f700b 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,13 @@ typings/ # next.js build output .next +.vite-react-mcp-webpack/ +**/.vite-react-mcp-webpack/ + +# Playwright output +test-results/ +playwright-report/ +blob-report/ dist @@ -86,8 +93,6 @@ playground/package-lock.json playground/node_modules playground/pnpm-lock.yaml -pnpm-lock.yaml - # for scripts clones @@ -103,4 +108,4 @@ dist/ **/.yalc/** yalc.lock **/yalc.lock -.yalc/ \ No newline at end of file +.yalc/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..57e3388 --- /dev/null +++ b/.npmignore @@ -0,0 +1,38 @@ +# Workspace-only files. Publishable packages live under packages/* and use +# their own package.json "files" allowlists. +playground/ +packages/ +scripts/ + +# Local dependencies and generated artifacts +node_modules/ +dist/ +coverage/ +.nyc_output/ +.turbo/ +.vite-inspect/ +test-results/ +playwright-report/ +blob-report/ + +# Local app/tooling state +.cursor/ +.idea/ +.cache/ +.npm/ +.yalc/ +**/.yalc/ +yalc.lock +**/yalc.lock + +# Logs and packed tarballs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.tgz + +# Environment and OS files +.env +.DS_Store +*.timestamp-*.* diff --git a/CHANGELOG b/CHANGELOG index e382bf1..61fc49b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,15 @@ +## 0.1.0 + +Initial Agentic React scoped monorepo release under `@agentic-react/*`. + +- Publish `@agentic-react/core` as the bundler-agnostic runtime and MCP primitive package. +- Publish `@agentic-react/vite`, `@agentic-react/webpack`, and `@agentic-react/next` as local-dev adapter packages. +- Rename the browser runtime globals, generated files, and playground packages to Agentic React names. + ## 0.3.0 Introduce Custom Tools - where users can define custom functions and declare as MCP tools. ## 0.2.4 -Introduce 4 tools running in browser runtime to get context of React Fibre tree. \ No newline at end of file +Introduce 4 tools running in browser runtime to get context of React Fibre tree. diff --git a/README.md b/README.md index 00f2d95..fd42657 100644 --- a/README.md +++ b/README.md @@ -1,205 +1,171 @@ -# vite-react-mcp +# Agentic React -[![npm version][npm-version-src]][npm-version-href] -[![npm downloads][npm-downloads-src]][npm-downloads-href] +Agentic React is a monorepo for React runtime inspection and local-dev MCP integrations. The packages are published under the `@agentic-react` namespace. -A Vite plugin that creates an MCP server to help LLMs understand your React App context +## Demos -## Features +### Single Select -- `highlight-component` - - description: Highlight React component based on the component name. - - params: - - `componentName`: string + - ![highlight-component](./playground/demo/demo_highlight_component.gif) +### Multiselect -- `get-component-states` - - description: Get the React component props, states, and contexts in JSON structure format. - - params: - - `componentName`: string + - ![get-component-states](./playground/demo/demo_get_states.gif) +## Packages -- `get-component-tree` - - description: Get the React component tree of the current page in ASCII format. - - params: - - `allComponent`: boolean, if truthy, return a tree for all components instead of your self-defined components only. +- `@agentic-react/core`: bundler-agnostic browser runtime, React selection toolkit, and MCP primitives. +- `@agentic-react/vite`: Vite adapter for local dev. +- `@agentic-react/webpack`: Webpack adapter for local dev. +- `@agentic-react/next`: Next.js adapter for local dev. - ![get-component-tree](./playground/demo/demo_get_component_tree.gif) +For full local-dev features, install the adapter for your bundler. The adapter depends on `@agentic-react/core` internally, so app users do not need to import both packages. -- `get-unnecessary-rerenders` - - description: Get the wasted re-rendered components of the current page. - - params: - - `timeframe`: number, if present, only get unnecessary renders within the last `timeframe` seconds. If not, get all unnecessary renders happened on the current page. - - `allComponent`: boolean, if truthy, get unnecessary renders for all components instead of self-defined components only. +## Local Dev Usage - ![get-unnecessary-rerenders](./playground/demo/demo_unnecessary_renders.gif) - -- Custom Tools - - You can now define your own tool functions in JS/TS in your Vite project, and inject it - to the plugin. - - ![custom-tools](./playground/demo/demo_custom_tools.gif) - -## Getting Started - -### Installation +### Vite ```bash -pnpm install vite-react-mcp -D +pnpm install @agentic-react/vite -D ``` -You also need `@babel/preset-react` installed, as this plugins traverses AST to collect your React components names. +```ts +// vite.config.ts +import { defineConfig } from 'vite'; +import AgenticReact from '@agentic-react/vite'; -```bash -pnpm install @babel/preset-react +export default defineConfig({ + plugins: [AgenticReact()], +}); ``` -### Usage +The Vite adapter injects the core browser runtime, attaches the runtime bridge to the dev server, and exposes MCP over: -#### Built-in tools +```text +http://localhost:/mcp +``` -```ts -// vite.config.ts -import ReactMCP from 'vite-react-mcp' +### Webpack -export default defineConfig({ - plugins: [ReactMCP()], -}) +```bash +pnpm install @agentic-react/webpack -D ``` -#### Custom tools +```js +// webpack.config.mjs +import withAgenticReactWebpack from '@agentic-react/webpack'; -Define your own tool in your Vite project, e.g. +export default (env, argv) => + withAgenticReactWebpack(config, { mode: argv.mode }); +``` -```ts -// any ts file in your Vite project -import type { ToolResultValue } from 'vite-react-mcp'; +The Webpack adapter prepends a generated runtime entry, attaches the runtime bridge to webpack-dev-server, and exposes MCP at: -export default function myCustomTool(args: { message: string }): ToolResultValue { - const { message } = args; - console.log(`[custom-tool/log1] ${message}`); - return { - success: true, - message: `Log1 received: ${message}`, - }; -} +```text +http://localhost:/mcp ``` -```ts -// vite.config.ts -import ReactMCP from 'vite-react-mcp' -import log1 from 'path/to/your/module' +### Next.js -export default defineConfig({ - plugins: [ReactMCP({ - customTools: [ - { - name: 'log1', - description: 'Log1', - schema: z.object({ - message: z.string(), - }), - clientFunction: log1, - } - ] - })], -}) +```bash +pnpm install @agentic-react/next -D ``` -Then run your app in dev. -> Note: vite-react-mcp is meant to be used in dev environment only +```js +// next.config.mjs +import withAgenticReactNext from '@agentic-react/next'; -At this point, you already can access `window.__VITE_REACT_MCP_TOOLS__` to use the tools in Developer panel on your browser. +export default withAgenticReactNext(nextConfig); +``` -To expose it as an MCP server, setup MCP configuration in your MCP client. +The Next adapter injects the browser runtime through Next's Webpack config and starts a local bridge server. By default MCP is available at: -- For Cursor, create a `./cursor/mcp.json` at the root level of your react project. +```text +http://127.0.0.1:51426/mcp +``` - ```json - { - "mcpServers": { - "vite-react-mcp": { - "url": "http://localhost:3000/sse" - } - } - } - ``` +## Runtime-Only Usage - Make sure the port is the same as your react app +Use `@agentic-react/core` directly when you only need browser-side selection/runtime APIs and do not need local source-file lookup or MCP dev-server wiring. -- For Claude Desktop, it requires a bit of workaround. If you are interested, you can take a look at [this thread](https://github.com/orgs/modelcontextprotocol/discussions/16). +```bash +pnpm install @agentic-react/core +``` - The reason is Claude MCP Client does execution based on command, while what we have here is HTTP based API. You need to write a script acting as a bridge to make it look like execution based. +```ts +import { createSelectionToolkit } from '@agentic-react/core'; +const toolkit = createSelectionToolkit(); +toolkit.enable(); +``` -### How it works +Runtime-only mode can inspect the live browser tree, but it cannot read or edit local files. Source lookup and local MCP transport are the adapter layer's job. -This plugin bridges an MCP server with your React app's runtime, enabling LLMs to inspect and interact with your components in a live browser session. Here's the full picture: +## What Adapters Add -#### Architecture overview +`@agentic-react/core` runs in the browser and can select elements, inspect React fibers, highlight components, format selection context, and expose `window.__AGENTIC_REACT__` / `window.__AGENTIC_REACT_TOOLS__`. -``` -MCP Client (Cursor, etc.) - │ - │ SSE + HTTP POST (/sse, /messages) - ▼ -Vite Dev Server (Node.js) - ├── MCP Server (handles tool listing & calls) - └── HMR WebSocket - │ - │ custom events via Vite HMR - ▼ - Browser - ├── overlay.js (tool implementations + WebSocket listeners) - ├── bippy (React fiber access via __REACT_DEVTOOLS_GLOBAL_HOOK__) - └── window.__VITE_REACT_MCP_TOOLS__ (tool registry) -``` +Bundler adapters add local-dev capabilities that the runtime cannot know on its own: -#### Step by step +- inject the core runtime automatically +- attach a local MCP Streamable HTTP `/mcp` endpoint +- bridge MCP calls from Node to the browser runtime +- provide local source-root context for source lookup +- keep dev-only tooling out of production bundles -1. **Babel AST pass (build time)** — During Vite's `transform` hook, each `.js/.jsx/.ts/.tsx` file is parsed with `@babel/preset-react` to collect all user-defined React component names (function components, class components, `memo`/`forwardRef` wrappers, etc.). These names are stored and later injected into the page as `window.__REACT_COMPONENTS__`. +## Custom Tools -2. **Browser-side injection (runtime)** — At dev startup, scripts are injected into the HTML ``: - - The collected component names are set on `window.__REACT_COMPONENTS__`. - - `overlay.js` is loaded as a module. It uses [bippy](https://github.com/nicholascostadev/bippy) to install a `__REACT_DEVTOOLS_GLOBAL_HOOK__` on `window`, giving direct access to the React fiber tree **without requiring the React DevTools browser extension**. It also hooks into React's commit lifecycle (`onCommitFiberRoot`) to continuously track fiber roots and detect unnecessary re-renders. - - The overlay exposes all built-in tool functions (highlight, tree, states, re-renders) on `window.__VITE_REACT_MCP_TOOLS__` and registers Vite HMR listeners (`import.meta.hot.on(...)`) for each tool. +Adapters accept browser-side custom tools. Import shared types from the adapter package you use: -3. **MCP server (SSE transport)** — When the Vite dev server starts, the plugin attaches two HTTP endpoints: - - `GET /sse` — Establishes a long-lived Server-Sent Events connection with the MCP client. - - `POST /messages?sessionId=` — Receives JSON-RPC tool-call requests from the MCP client. +```ts +import type { ToolResultValue } from '@agentic-react/vite'; - The MCP server advertises all built-in and custom tools (with JSON Schema descriptions derived from their Zod schemas) so any MCP-compatible client can discover and invoke them. +export default function logMessage(args: { message: string }): ToolResultValue { + return { + success: true, + message: `Received: ${args.message}`, + }; +} +``` -4. **Tool call flow** — When an MCP client invokes a tool (e.g. `highlight-component`): - 1. The MCP server receives the JSON-RPC request via the `/messages` endpoint. - 2. It validates the arguments against the tool's Zod schema. - 3. It sends a custom event through Vite's HMR WebSocket to the browser (e.g. `highlight-component` with the serialized args). - 4. The browser-side HMR listener picks up the event, executes the tool function against the live React fiber tree, and sends the result back via another HMR event (e.g. `highlight-component-response`). - 5. The MCP server awaits this response, wraps it in a JSON-RPC result, and streams it back to the MCP client over SSE. +```ts +import { defineConfig } from 'vite'; +import AgenticReact from '@agentic-react/vite'; +import { z } from 'zod'; +import logMessage from './src/tools/logMessage'; -5. **Custom tools** — User-defined tools follow the same WebSocket round-trip. Their handler functions are registered in the browser at startup via dynamic `import()` or inline injection, and corresponding HMR listeners are created automatically. +export default defineConfig({ + plugins: [ + AgenticReact({ + customTools: [ + { + name: 'log-message', + description: 'Log a message in the browser runtime', + schema: z.object({ message: z.string() }), + clientFunction: logMessage, + }, + ], + }), + ], +}); +``` -### Test +## Development ```bash -pnpm run playground +pnpm run build +pnpm run playground:vite +pnpm run playground:webpack +pnpm run playground:next +pnpm run playground:nx-mf ``` -The playground contains a simple user profile application to test React component interactions. +For e2e automation, Playwright uses fixed local ports configured in the playground package configs. ## Acknowledgement -This project is inspired by [vite-plugin-vue-mcp](https://github.com/webfansplz/vite-plugin-vue-mcp). Thanks for the awesome idea bridging mcp and devtools. - +This project is inspired by [vite-plugin-vue-mcp](https://github.com/webfansplz/vite-plugin-vue-mcp). ## License MIT - -[npm-version-src]: https://img.shields.io/npm/v/vite-react-mcp?style=flat&colorA=080f12&colorB=1fa669 -[npm-version-href]: https://npmjs.com/package/vite-react-mcp -[npm-downloads-src]: https://img.shields.io/npm/dm/vite-react-mcp?style=flat&colorA=080f12&colorB=1fa669 -[npm-downloads-href]: https://npmjs.com/package/vite-react-mcp diff --git a/biome.json b/biome.json index 7d37368..7b5f320 100644 --- a/biome.json +++ b/biome.json @@ -3,19 +3,27 @@ "vcs": { "enabled": false, "clientKind": "git", - "useIgnoreFile": false + "useIgnoreFile": true }, "files": { "ignoreUnknown": false, "include": [ - "src/**/*.ts", - "src/**/*.js", + "packages/**/*.ts", + "packages/**/*.js", "playground/**/*.ts", "playground/**/*.tsx", "*.ts", "*.tsx" ], - "ignore": ["node_modules", "dist", "coverage", "css-to-tailwind.ts"] + "ignore": [ + "node_modules", + "dist", + "coverage", + ".next", + "playground/**/.next/**", + "playground/**/test-results/**", + "css-to-tailwind.ts" + ] }, "formatter": { "enabled": true, diff --git a/package.json b/package.json index 93d01e5..b6357fc 100644 --- a/package.json +++ b/package.json @@ -1,58 +1,56 @@ { - "name": "vite-react-mcp", + "name": "agentic-react-monorepo", + "private": true, "type": "module", - "version": "0.3.0", - "description": "Vite plugin for React MCP", - "main": "dist/index.js", - "workspaces": [ - "packages/*", - "playground/*" - ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "dev": "pnpm -r run start", - "build": "npx shx rm -rf dist && tsc && npx shx cp CHANGELOG dist/CHANGELOG", - "build:src": "npx shx rm -rf dist && tsc --project tsconfig.json --outDir dist --rootDir src && npx shx cp CHANGELOG dist/CHANGELOG", - "playground": "pnpm --filter user-profile-app run start", + "build": "node scripts/with-build-lock.mjs pnpm -r --filter './packages/**' run build", + "build:src": "pnpm --filter @agentic-react/core run build:src", + "dev": "pnpm -r --parallel --if-present run start", + "playground": "pnpm --filter agentic-react-vite-playground run start", + "playground:vite": "pnpm --filter agentic-react-vite-playground run start", + "playground:next": "pnpm --filter agentic-react-next-playground run start", + "playground:webpack": "pnpm --filter agentic-react-webpack-playground run start", + "playground:nx-mf": "pnpm -C playground/agentic-react-nx-module-federation-playground run start", "format": "pnpm biome format --write", "lint": "pnpm biome lint --write", - "check": "pnpm biome check --write" + "check": "pnpm biome check --write", + "changeset": "changeset", + "changeset:status": "changeset status --since=origin/main", + "version-packages": "changeset version", + "release": "changeset publish", + "test": "pnpm run test:e2e", + "test:e2e": "node scripts/run-e2e.mjs" }, - "keywords": [ - "vite", - "react", - "mcp", - "devtools", - "llm", - "plugin" - ], - "author": { - "name": "jazelly", - "email": "xzha4350@gmail.com" + "pnpm": { + "overrides": { + "@remix-run/router": "1.23.2", + "caniuse-lite": "1.0.30001793", + "fast-uri": "3.1.2", + "js-yaml": "4.1.1", + "read-yaml-file@1.1.0>js-yaml": "3.14.1", + "lodash": "4.18.1", + "minimatch": "3.1.5", + "next": "15.5.18", + "path-to-regexp@>=8.0.0 <8.4.0": "8.4.2", + "picomatch@2.3.1": "2.3.2", + "picomatch@4.0.2": "4.0.4", + "postcss": "8.5.15", + "qs": "6.15.2", + "rollup": "4.60.4", + "serialize-javascript": "7.0.5", + "uuid": "11.1.1", + "vite": "6.4.2", + "webpack": "5.107.1", + "webpack-dev-server": "5.2.4", + "ws": "8.21.0", + "yaml": "1.10.3" + } }, - "files": [ - "dist" - ], - "license": "MIT", "packageManager": "pnpm@10.6.5", - "peerDependencies": { - "@babel/preset-react": ">=6", - "vite": ">=4" - }, "devDependencies": { - "@babel/core": "^7.26.10", - "@babel/types": "^7.27.0", "@biomejs/biome": "1.9.4", - "@rollup/pluginutils": "^5.1.4", - "@types/react-reconciler": "0.26.7", + "@changesets/cli": "^2.31.0", "shx": "0.4.0", - "typescript": "^5.8.2", - "vite": "^6.2.3" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.7.0", - "bippy": "0.3.8", - "zod": "^3.22.4", - "zod-to-json-schema": "^3.23.5" + "typescript": "^5.8.2" } } diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..102207d --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 webfansplz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..13a8e7b --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,20 @@ +# @agentic-react/core + +Bundler-agnostic Agentic React runtime primitives. + +Use this package when you need browser-side React selection and inspection without Vite, Webpack, or Next.js adapter wiring. + +```ts +import { createSelectionToolkit } from '@agentic-react/core'; + +const toolkit = createSelectionToolkit(); +toolkit.enable(); +``` + +For full local-dev MCP features, install an adapter instead: + +- `@agentic-react/vite` +- `@agentic-react/webpack` +- `@agentic-react/next` + +Adapters depend on `@agentic-react/core` internally and add runtime injection, bridge transport, MCP endpoints, and source-root context. diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..abac7b5 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,84 @@ +{ + "name": "@agentic-react/core", + "type": "module", + "version": "0.1.0", + "description": "Bundler-agnostic Agentic React runtime selection and MCP primitives", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "shx rm -rf dist && tsc && shx cp -r src/assets dist/assets && shx cp ../../CHANGELOG dist/CHANGELOG", + "build:src": "shx rm -rf dist && tsc --project tsconfig.json --outDir dist --rootDir src && shx cp -r src/assets dist/assets && shx cp ../../CHANGELOG dist/CHANGELOG" + }, + "keywords": [ + "agentic-react", + "react", + "mcp", + "visual-edit", + "agents", + "devtools", + "llm", + "runtime", + "selection" + ], + "author": { + "name": "jazelly", + "email": "xzha4350@gmail.com" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "devDependencies": { + "@types/react-reconciler": "0.26.7" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "bippy": "^0.5.39", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.23.5", + "ws": "^8.21.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./bridge": { + "types": "./dist/bridge/server.d.ts", + "default": "./dist/bridge/server.js" + }, + "./mcp": { + "types": "./dist/mcp/index.d.ts", + "default": "./dist/mcp/index.js" + }, + "./overlay": { + "types": "./dist/overlay.d.ts", + "default": "./dist/overlay.js" + }, + "./select": { + "types": "./dist/select.d.ts", + "default": "./dist/select.js" + }, + "./shared/const": { + "types": "./dist/shared/const.d.ts", + "default": "./dist/shared/const.js" + }, + "./shared/custom-tools-script": { + "types": "./dist/shared/custom_tools_script.d.ts", + "default": "./dist/shared/custom_tools_script.js" + }, + "./shared/protocol": { + "types": "./dist/shared/protocol.d.ts", + "default": "./dist/shared/protocol.js" + }, + "./shared/types": { + "types": "./dist/shared/types.d.ts", + "default": "./dist/shared/types.js" + } + } +} diff --git a/packages/core/src/assets/agentic-react-toolkit-logo.png b/packages/core/src/assets/agentic-react-toolkit-logo.png new file mode 100644 index 0000000..029bf21 Binary files /dev/null and b/packages/core/src/assets/agentic-react-toolkit-logo.png differ diff --git a/packages/core/src/bridge/server.ts b/packages/core/src/bridge/server.ts new file mode 100644 index 0000000..467c009 --- /dev/null +++ b/packages/core/src/bridge/server.ts @@ -0,0 +1,289 @@ +import { randomUUID } from 'node:crypto'; +import { type WebSocket, WebSocketServer } from 'ws'; +import { + BRIDGE_WS_PATH, + type BridgeMessage, + type BridgeRequestEvent, +} from '../shared/protocol.js'; + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + socket: WebSocket; + timeoutId: NodeJS.Timeout; +} + +const DEFAULT_TIMEOUT_MS = 10000; +const BRIDGE_CONNECT_POLL_MS = 50; + +interface RuntimeBridgeRequestOptions { + acceptResponse?: (value: unknown) => boolean; + broadcast?: boolean; + timeoutMs?: number; +} + +const isBridgeMessage = (value: unknown): value is BridgeMessage => { + if (!value || typeof value !== 'object') { + return false; + } + + const message = value as { type?: unknown }; + return ( + message.type === 'bridge:request' || message.type === 'bridge:response' + ); +}; + +export class RuntimeBridgeServer { + private wsServer: WebSocketServer | null = null; + private activeSocket: WebSocket | null = null; + private readonly pendingRequests = new Map(); + private readonly sockets = new Set(); + + attach(httpServer: any) { + this.wsServer = new WebSocketServer({ noServer: true }); + + httpServer.on('upgrade', (request, socket, head) => { + const requestUrl = request.url || ''; + const pathname = requestUrl.split('?')[0]; + + if (pathname !== BRIDGE_WS_PATH || !this.wsServer) { + return; + } + + this.wsServer.handleUpgrade(request, socket, head, (websocket) => { + this.wsServer?.emit('connection', websocket, request); + }); + }); + + this.wsServer.on('connection', (websocket) => { + this.sockets.add(websocket); + this.activeSocket = websocket; + + websocket.on('message', (messageBuffer) => { + try { + const parsedMessage = JSON.parse(messageBuffer.toString()) as unknown; + + if (!isBridgeMessage(parsedMessage)) { + return; + } + + if (parsedMessage.type !== 'bridge:response') { + return; + } + + const pendingRequest = this.pendingRequests.get(parsedMessage.id); + if (!pendingRequest) { + return; + } + + this.pendingRequests.delete(parsedMessage.id); + clearTimeout(pendingRequest.timeoutId); + + if (parsedMessage.ok) { + pendingRequest.resolve(parsedMessage.payload); + return; + } + + pendingRequest.reject( + new Error(parsedMessage.error || 'Bridge response failed'), + ); + } catch (_error) { + // ignore malformed response + } + }); + + websocket.on('close', () => { + this.sockets.delete(websocket); + if (this.activeSocket === websocket) { + this.activeSocket = null; + } + + for (const [requestId, pendingRequest] of this.pendingRequests) { + if (pendingRequest.socket !== websocket) { + continue; + } + clearTimeout(pendingRequest.timeoutId); + pendingRequest.reject( + new Error('Bridge connection closed before receiving response'), + ); + this.pendingRequests.delete(requestId); + } + }); + }); + } + + async request( + event: BridgeRequestEvent, + payload: unknown, + optionsOrTimeoutMs: + | RuntimeBridgeRequestOptions + | number = DEFAULT_TIMEOUT_MS, + ): Promise { + const options = + typeof optionsOrTimeoutMs === 'number' + ? { timeoutMs: optionsOrTimeoutMs } + : optionsOrTimeoutMs; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const sockets = await this.waitForOpenSockets(timeoutMs); + const orderedSockets = this.orderSocketsByActivity(sockets); + + if (options.broadcast) { + const responses = await Promise.allSettled( + orderedSockets.map((socket) => + this.requestFromSocket(socket, event, payload, timeoutMs), + ), + ); + const fulfilledResponses = responses + .filter( + (response): response is PromiseFulfilledResult => + response.status === 'fulfilled', + ) + .map((response) => response.value); + const acceptedResponse = fulfilledResponses.find( + (response) => + !options.acceptResponse || options.acceptResponse(response), + ); + if (acceptedResponse !== undefined) { + return acceptedResponse; + } + if (fulfilledResponses.length > 0) { + return fulfilledResponses[0]; + } + const rejectedResponse = responses.find( + (response): response is PromiseRejectedResult => + response.status === 'rejected', + ); + throw rejectedResponse?.reason instanceof Error + ? rejectedResponse.reason + : new Error(`Bridge request failed for ${event}`); + } + + let firstResponse: unknown; + let hasFirstResponse = false; + let lastError: Error | null = null; + + for (const socket of orderedSockets) { + try { + const response = await this.requestFromSocket( + socket, + event, + payload, + timeoutMs, + ); + if (!hasFirstResponse) { + firstResponse = response; + hasFirstResponse = true; + } + if (!options.acceptResponse || options.acceptResponse(response)) { + return response; + } + } catch (error) { + lastError = + error instanceof Error ? error : new Error('Bridge request failed'); + } + } + + if (hasFirstResponse) { + return firstResponse; + } + if (lastError) { + throw lastError; + } + throw new Error(`Bridge request failed for ${event}`); + } + + private async requestFromSocket( + socket: WebSocket, + event: BridgeRequestEvent, + payload: unknown, + timeoutMs: number, + ): Promise { + const requestId = randomUUID(); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingRequests.delete(requestId); + reject(new Error(`Bridge request timed out for ${event}`)); + }, timeoutMs); + + this.pendingRequests.set(requestId, { + resolve, + reject, + socket, + timeoutId, + }); + + const message: BridgeMessage = { + type: 'bridge:request', + id: requestId, + event, + payload, + }; + + try { + socket.send(JSON.stringify(message)); + } catch (error) { + this.pendingRequests.delete(requestId); + clearTimeout(timeoutId); + reject( + error instanceof Error ? error : new Error('Bridge send failed'), + ); + } + }); + } + + private getOpenSockets(): WebSocket[] { + const sockets = Array.from(this.sockets).filter( + (socket) => socket.readyState === socket.OPEN, + ); + if ( + this.activeSocket && + this.activeSocket.readyState !== this.activeSocket.OPEN + ) { + this.activeSocket = null; + } + return sockets; + } + + private orderSocketsByActivity(sockets: WebSocket[]): WebSocket[] { + const activeSocket = + this.activeSocket && + this.activeSocket.readyState === this.activeSocket.OPEN + ? this.activeSocket + : null; + const newestFirst = [...sockets].reverse(); + + if (!activeSocket) { + return newestFirst; + } + + return [ + activeSocket, + ...newestFirst.filter((socket) => socket !== activeSocket), + ]; + } + + private async waitForOpenSockets(timeoutMs: number): Promise { + const openSockets = this.getOpenSockets(); + if (openSockets.length > 0) { + return openSockets; + } + + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const intervalId = setInterval(() => { + const nextOpenSockets = this.getOpenSockets(); + if (nextOpenSockets.length > 0) { + clearInterval(intervalId); + resolve(nextOpenSockets); + return; + } + + if (Date.now() - startedAt >= timeoutMs) { + clearInterval(intervalId); + reject(new Error('No active runtime bridge connection')); + } + }, BRIDGE_CONNECT_POLL_MS); + }); + } +} diff --git a/src/core/tools/component_highlighter.ts b/packages/core/src/core/tools/component_highlighter.ts similarity index 96% rename from src/core/tools/component_highlighter.ts rename to packages/core/src/core/tools/component_highlighter.ts index e37706d..b9b7386 100644 --- a/src/core/tools/component_highlighter.ts +++ b/packages/core/src/core/tools/component_highlighter.ts @@ -1,6 +1,6 @@ -import { target } from '../../shared/const'; -import { InvalidInputError } from '../../shared/errors'; -import { getDOMNodesByComponentName } from './util'; +import { target } from '../../shared/const.js'; +import { InvalidInputError } from '../../shared/errors.js'; +import { getDOMNodesByComponentName } from './util.js'; export const highlightComponent = ( componentName: string, diff --git a/src/core/tools/component_state_viewer.ts b/packages/core/src/core/tools/component_state_viewer.ts similarity index 88% rename from src/core/tools/component_state_viewer.ts rename to packages/core/src/core/tools/component_state_viewer.ts index 241af06..5246d71 100644 --- a/src/core/tools/component_state_viewer.ts +++ b/packages/core/src/core/tools/component_state_viewer.ts @@ -1,12 +1,12 @@ import type { Fiber } from 'bippy'; -import { FiberNotFoundError } from '../../shared/errors'; +import { FiberNotFoundError } from '../../shared/errors.js'; import { getCurrentContexts, getCurrentProps, getCurrentStates, -} from '../../shared/util'; -import type { HookNode } from '../../types/internal'; -import { getFibersByComponentName } from './util'; +} from '../../shared/util.js'; +import type { HookNode } from '../../types/internal.js'; +import { getFibersByComponentName } from './util.js'; interface ComponentStateAndContext { props: Fiber['memoizedProps']; diff --git a/src/core/tools/component_viewer.ts b/packages/core/src/core/tools/component_viewer.ts similarity index 62% rename from src/core/tools/component_viewer.ts rename to packages/core/src/core/tools/component_viewer.ts index 4abf567..7beede3 100644 --- a/src/core/tools/component_viewer.ts +++ b/packages/core/src/core/tools/component_viewer.ts @@ -1,7 +1,13 @@ import type { Fiber } from 'bippy'; -import { FiberRootsNotFoundError } from '../../shared/errors'; -import { getAllFiberRoots, getCurrentProps, getDisplayNameForFiber } from '../../shared/util'; -import type { ComponentTreeNode } from '../../types/internal'; +import { FiberRootsNotFoundError } from '../../shared/errors.js'; +import { getAllFiberRoots, getDisplayNameForFiber } from '../../shared/util.js'; +import type { ComponentTreeNode } from '../../types/internal.js'; +import { isProjectOwnedFiber } from './source_ownership.js'; + +interface ComponentTreeNodeWithOwnership extends ComponentTreeNode { + isProjectOwned: boolean; + children: ComponentTreeNodeWithOwnership[]; +} /** * Recursively build a component tree with name from a fiber tree @@ -9,16 +15,20 @@ import type { ComponentTreeNode } from '../../types/internal'; * @param prevResult The previous result of the component tree * @returns The component tree */ -const buildComponentTree = (fiber: Fiber, prevResult: ComponentTreeNode) => { +const buildComponentTree = async ( + fiber: Fiber, + prevResult: ComponentTreeNodeWithOwnership, +) => { let fiberBase = fiber; while (fiberBase) { const componentName = getDisplayNameForFiber(fiberBase); const nodeResult = { name: componentName, children: [], + isProjectOwned: await isProjectOwnedFiber(fiberBase), }; prevResult.children.push(nodeResult); - buildComponentTree(fiberBase.child, nodeResult); + await buildComponentTree(fiberBase.child, nodeResult); fiberBase = fiberBase.sibling; } }; @@ -29,17 +39,15 @@ const buildComponentTree = (fiber: Fiber, prevResult: ComponentTreeNode) => { * @param trimTreeNode The tree node to trim to */ const trimComponentTree = ( - oldTreeNode: ComponentTreeNode, - trimTreeNode: ComponentTreeNode, + oldTreeNode: ComponentTreeNodeWithOwnership, + trimTreeNode: ComponentTreeNodeWithOwnership, ) => { let nextAttachNode = trimTreeNode; - if ( - oldTreeNode.name === 'createRoot()' || - window.__REACT_COMPONENTS__.includes(oldTreeNode.name) - ) { + if (oldTreeNode.name === 'createRoot()' || oldTreeNode.isProjectOwned) { nextAttachNode = { name: oldTreeNode.name, children: [], + isProjectOwned: oldTreeNode.isProjectOwned, }; trimTreeNode.children.push(nextAttachNode); } @@ -49,17 +57,24 @@ const trimComponentTree = ( } }; +const stripOwnership = ( + node: ComponentTreeNodeWithOwnership, +): ComponentTreeNode => ({ + name: node.name, + children: node.children.map(stripOwnership), +}); + /** * Given the component tree of current page * @param matchId An identifier to filter the component tree. Only components' name containing this id will be included */ -export const getComponentTree = ({ +export const getComponentTree = async ({ allComponents = false, debugMode = false, }: { allComponents?: boolean; debugMode?: boolean; -} = {}) => { +} = {}): Promise => { // Get all fiber roots const roots = getAllFiberRoots(); if (!roots || roots.length === 0) { @@ -70,12 +85,13 @@ export const getComponentTree = ({ console.debug('getComponentTree - roots', roots); } - const result: ComponentTreeNode = { + const result: ComponentTreeNodeWithOwnership = { name: '__BASE__', children: [], + isProjectOwned: false, }; for (const root of roots) { - buildComponentTree(root.current, result); + await buildComponentTree(root.current, result); } if (debugMode) { @@ -85,6 +101,7 @@ export const getComponentTree = ({ const trimmedResult = { name: '__BASE__', children: [], + isProjectOwned: false, }; if (debugMode) { @@ -99,7 +116,7 @@ export const getComponentTree = ({ if (debugMode) { console.debug('getComponentTree - trimmed result', trimmedResult); } - finalResult = trimmedResult.children[0]; + finalResult = trimmedResult.children[0] ?? trimmedResult; } // there will always a be a root node under __BASE__ @@ -107,5 +124,5 @@ export const getComponentTree = ({ finalResult.name = 'root'; } - return finalResult; + return stripOwnership(finalResult); }; diff --git a/packages/core/src/core/tools/selection_context.ts b/packages/core/src/core/tools/selection_context.ts new file mode 100644 index 0000000..5815f39 --- /dev/null +++ b/packages/core/src/core/tools/selection_context.ts @@ -0,0 +1,1061 @@ +import { + type Fiber, + getDisplayName, + getFiberFromHostInstance, + getFiberStack, + isCompositeFiber, + isInstrumentationActive, + traverseFiber, +} from 'bippy'; +import { + type StackFrame, + formatOwnerStack, + getOwnerStack, + getSource, + hasDebugStack, + isSourceFile, + normalizeFileName, + parseStack, +} from 'bippy/source'; +import { buildSelectionSourcePreview } from '../../shared/selection_context_format.js'; +import type { + SelectionContext, + SelectionExternalComponent, + SelectionResolvedSource, + SelectionSourceTraceFrame, + SelectionStackFrame, +} from '../../shared/types.js'; +import { getDisplayNameForFiber } from '../../shared/util.js'; +import { createElementSelector } from './selection_selector.js'; + +const MAX_HTML_PREVIEW_LENGTH = 1200; +const MAX_STACK_FRAMES = 24; +const MAX_SOURCE_FRAMES = 12; + +const NON_COMPONENT_PREFIXES = [ + '_', + '$', + 'motion.', + 'styled.', + 'Styled(', + 'chakra.', + 'ark.', + 'Primitive.', + 'Slot.', +]; + +const INTERNAL_COMPONENT_NAMES = new Set([ + 'Suspense', + 'Fragment', + 'StrictMode', + 'Profiler', + 'SuspenseList', + 'Activity', + 'Cache', + 'root', + 'createRoot()', + 'SlotClone', + 'InnerLayoutRouter', + 'RedirectErrorBoundary', + 'RedirectBoundary', + 'HTTPAccessFallbackErrorBoundary', + 'HTTPAccessFallbackBoundary', + 'LoadingBoundary', + 'ErrorBoundary', + 'InnerScrollAndFocusHandler', + 'ScrollAndFocusHandler', + 'RenderFromTemplateContext', + 'OuterLayoutRouter', + 'body', + 'html', + 'DevRootHTTPAccessFallbackBoundary', + 'AppDevOverlayErrorBoundary', + 'AppDevOverlay', + 'HotReload', + 'Router', + 'ErrorBoundaryHandler', + 'AppRouter', + 'ServerRoot', + 'SegmentStateProvider', + 'RootErrorBoundary', + 'LoadableComponent', + 'MotionDOMComponent', +]); + +const SERVER_COMPONENT_URL_PREFIXES = ['about://React/', 'rsc://React/']; +const SYMBOLICATION_TIMEOUT_MS = 5000; +const NODE_MODULES_PATTERN = /(?:^|[/\\])node_modules[/\\]/g; +const VITE_OPTIMIZED_DEPS_PATTERN = /[/\\]\.vite[/\\]deps[^/\\]*[/\\]/g; +const FILE_EXTENSION_PATTERN = /\.[mc]?[jt]sx?$/i; +const VITE_INTERNAL_CHUNK_PATTERN = /^chunk-[A-Za-z0-9_-]+$/; +const PATH_SEPARATOR_PATTERN = /[/\\]/; +const NAME_AT_VERSION_PATTERN = /^(.+?)@v?\d/; +const INTRINSIC_ELEMENT_NAMES = new Set([ + 'a', + 'abbr', + 'address', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'bdi', + 'bdo', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'cite', + 'code', + 'col', + 'colgroup', + 'data', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'dialog', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'label', + 'legend', + 'li', + 'link', + 'main', + 'map', + 'mark', + 'menu', + 'meta', + 'meter', + 'nav', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'picture', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'script', + 'search', + 'section', + 'select', + 'slot', + 'small', + 'source', + 'span', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'svg', + 'table', + 'tbody', + 'td', + 'template', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'u', + 'ul', + 'var', + 'video', + 'wbr', +]); + +const isIntrinsicElementName = (name: string | null): boolean => + Boolean(name && INTRINSIC_ELEMENT_NAMES.has(name)); + +const isUsefulComponentName = (name: string | null): boolean => { + if (!name) return false; + if (isIntrinsicElementName(name)) return false; + if (INTERNAL_COMPONENT_NAMES.has(name)) return false; + for (const prefix of NON_COMPONENT_PREFIXES) { + if (name.startsWith(prefix)) return false; + } + return true; +}; + +const isSourceComponentName = (name: string | null): boolean => { + if (!isUsefulComponentName(name)) return false; + if (!name) return false; + if (name.length < 2) return false; + if (name[0] !== name[0].toUpperCase()) return false; + if (name.endsWith('Provider') || name.endsWith('Context')) return false; + return true; +}; + +const inferComponentNameFromSource = ( + filePath: string, + functionName: string | null, +): string | null => { + if (functionName && isUsefulComponentName(functionName)) { + return functionName; + } + + const fileName = + filePath + .split('/') + .pop() + ?.replace(/\.[^.]+$/, '') ?? ''; + return /^[A-Z][A-Za-z0-9]*$/.test(fileName) ? fileName : null; +}; + +const normalizeSourceFilePath = (fileName: string): string => { + let normalizedFileName = normalizeFileName(fileName); + normalizedFileName = normalizedFileName.replace( + /^(?:\.\/)?\/?\([a-z][a-z0-9-]*\)\//, + '', + ); + if (normalizedFileName.startsWith('./')) { + normalizedFileName = normalizedFileName.slice(2); + } + return normalizedFileName.replace(/\?.*$/, ''); +}; + +const safeDecodeUriComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch (_error) { + return value; + } +}; + +const splitPathSegments = (path: string): string[] => + path.split(PATH_SEPARATOR_PATTERN).filter(Boolean); + +const isProjectSourceFrame = (fileName: string): boolean => { + if (!isSourceFile(fileName)) { + return false; + } + + const normalizedFileName = normalizeSourceFilePath(fileName); + const pathSegments = normalizedFileName.split('/').filter(Boolean); + return ( + !pathSegments.includes('node_modules') && + !pathSegments.includes('.vite') && + !normalizedFileName.includes('/@vite/') + ); +}; + +const isExternalSourceFrame = (fileName: string): boolean => { + const normalizedFileName = normalizeSourceFilePath(fileName); + const pathSegments = normalizedFileName.split('/').filter(Boolean); + const looksLikeJavaScriptSource = /\.(?:[cm]?js|jsx?|tsx?)$/i.test( + normalizedFileName, + ); + + if (!looksLikeJavaScriptSource) { + return false; + } + + return ( + pathSegments.includes('node_modules') || + pathSegments.includes('.vite') || + normalizedFileName.includes('/@vite/') + ); +}; + +const normalizeDisplayComponentName = (name: string | null): string | null => { + if (!name) return null; + const memoMatch = name.match(/^Memo\((.+)\)$/); + if (memoMatch) return memoMatch[1]; + const forwardRefMatch = name.match(/^ForwardRef\((.+)\)$/); + if (forwardRefMatch) return forwardRefMatch[1]; + return name; +}; + +const extractPackageNameFromFilePath = (filePath: string): string | null => { + const readNodeModulesPackage = (afterMarker: string): string | null => { + const [firstSegment, secondSegment] = splitPathSegments(afterMarker); + if (!firstSegment || firstSegment.startsWith('.')) return null; + if (!firstSegment.startsWith('@')) return firstSegment; + return secondSegment ? `${firstSegment}/${secondSegment}` : null; + }; + + const readViteOptimizedDependencyPackage = ( + afterMarker: string, + ): string | null => { + const firstSegment = splitPathSegments(afterMarker)[0]; + if (!firstSegment) return null; + + const packageStem = firstSegment.replace(FILE_EXTENSION_PATTERN, ''); + if (VITE_INTERNAL_CHUNK_PATTERN.test(packageStem)) return null; + if (!packageStem.startsWith('@')) return packageStem; + + const scopeBoundaryIndex = packageStem.indexOf('_'); + if (scopeBoundaryIndex === -1) return null; + return `${packageStem.slice(0, scopeBoundaryIndex)}/${packageStem.slice( + scopeBoundaryIndex + 1, + )}`; + }; + + const extractAfterLastMarker = ( + input: string, + pattern: RegExp, + read: (afterMarker: string) => string | null, + ): string | null => { + pattern.lastIndex = 0; + let lastMatch: RegExpExecArray | null = null; + let match = pattern.exec(input); + while (match !== null) { + lastMatch = match; + match = pattern.exec(input); + } + pattern.lastIndex = 0; + if (!lastMatch) return null; + return read(input.slice(lastMatch.index + lastMatch[0].length)); + }; + + const extractNameAtVersion = (segment: string | undefined): string | null => + segment?.match(NAME_AT_VERSION_PATTERN)?.[1] ?? null; + + const extractVersionedPackageFromUrl = ( + rawFilePath: string, + ): string | null => { + let url: URL; + try { + url = new URL(rawFilePath); + } catch (_error) { + return null; + } + + const segments = splitPathSegments(url.pathname).map( + safeDecodeUriComponent, + ); + for (const [index, segment] of segments.entries()) { + if (segment.startsWith('@')) { + const name = extractNameAtVersion(segments[index + 1]); + if (name) return `${segment}/${name}`; + continue; + } + + const name = extractNameAtVersion(segment); + if (name) return name; + } + return null; + }; + + const normalizedFileName = normalizeSourceFilePath(filePath); + const decodedFileName = safeDecodeUriComponent(normalizedFileName); + + const vitePackage = extractAfterLastMarker( + decodedFileName, + VITE_OPTIMIZED_DEPS_PATTERN, + readViteOptimizedDependencyPackage, + ); + if (vitePackage) { + return vitePackage; + } + + const nodeModulesPackage = extractAfterLastMarker( + decodedFileName, + NODE_MODULES_PATTERN, + readNodeModulesPackage, + ); + if (nodeModulesPackage) { + return nodeModulesPackage; + } + + return extractVersionedPackageFromUrl(filePath); +}; + +const isServerComponentUrl = (url: string): boolean => + SERVER_COMPONENT_URL_PREFIXES.some((prefix) => url.startsWith(prefix)); + +const devirtualizeServerUrl = (url: string): string => { + for (const prefix of SERVER_COMPONENT_URL_PREFIXES) { + if (!url.startsWith(prefix)) continue; + const environmentEndIndex = url.indexOf('/', prefix.length); + const querySuffixIndex = url.lastIndexOf('?'); + if (environmentEndIndex > -1 && querySuffixIndex > -1) { + return decodeURI(url.slice(environmentEndIndex + 1, querySuffixIndex)); + } + } + return url; +}; + +let cachedNextBasePath: string | undefined; + +const getNextBasePath = (): string => { + if (cachedNextBasePath !== undefined) return cachedNextBasePath; + + const source = document.querySelector( + 'script[src*="/_next/"]', + )?.src; + const pathname = source ? new URL(source).pathname : ''; + const assetPathIndex = pathname.indexOf('/_next/'); + cachedNextBasePath = + assetPathIndex > 0 ? pathname.slice(0, assetPathIndex) : ''; + return cachedNextBasePath; +}; + +const checkIsNextProject = (): boolean => + typeof document !== 'undefined' && + Boolean( + document.getElementById('__NEXT_DATA__') || + document.querySelector('nextjs-portal'), + ); + +const findNearestFiberElement = (element: Element): Element | null => { + let currentElement: Element | null = element; + while (currentElement) { + if (getFiberFromHostInstance(currentElement)) { + return currentElement; + } + currentElement = currentElement.parentElement; + } + return null; +}; + +const truncateText = (text: string, maxLength: number): string => { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}...`; +}; + +const getDomPreview = (element: Element): string => { + if ('outerHTML' in element && typeof element.outerHTML === 'string') { + return truncateText(element.outerHTML, MAX_HTML_PREVIEW_LENGTH); + } + + const tagName = element.tagName ? element.tagName.toLowerCase() : 'unknown'; + return `<${tagName}>`; +}; + +const mapOwnerStackFrame = (stackFrame: StackFrame): SelectionStackFrame => { + return { + functionName: stackFrame.functionName || null, + fileName: stackFrame.fileName + ? normalizeSourceFilePath(stackFrame.fileName) + : null, + lineNumber: stackFrame.lineNumber ?? null, + columnNumber: stackFrame.columnNumber ?? null, + }; +}; + +const symbolicateServerFrames = async ( + frames: StackFrame[], +): Promise => { + const serverFrameIndices: number[] = []; + const requestFrames: Array<{ + file: string; + methodName: string; + line1: number | null; + column1: number | null; + arguments: string[]; + }> = []; + + for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) { + const frame = frames[frameIndex]; + if (!frame.isServer || !frame.fileName) continue; + + serverFrameIndices.push(frameIndex); + requestFrames.push({ + file: devirtualizeServerUrl(frame.fileName), + methodName: frame.functionName ?? '', + line1: frame.lineNumber ?? null, + column1: frame.columnNumber ?? null, + arguments: [], + }); + } + + if (requestFrames.length === 0) return frames; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + SYMBOLICATION_TIMEOUT_MS, + ); + + try { + const response = await fetch( + `${getNextBasePath()}/__nextjs_original-stack-frames`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + frames: requestFrames, + isServer: true, + isEdgeServer: false, + isAppDirectory: true, + }), + signal: controller.signal, + }, + ); + + if (!response.ok) return frames; + + const results = (await response.json()) as Array<{ + status: string; + value?: { + originalStackFrame: { + file: string | null; + line1: number | null; + column1: number | null; + ignored: boolean; + } | null; + }; + }>; + const resolvedFrames = [...frames]; + + for ( + let resultIndex = 0; + resultIndex < serverFrameIndices.length; + resultIndex++ + ) { + const result = results[resultIndex]; + if (result?.status !== 'fulfilled') continue; + + const resolved = result.value?.originalStackFrame; + if (!resolved?.file || resolved.ignored) continue; + + const originalFrameIndex = serverFrameIndices[resultIndex]; + resolvedFrames[originalFrameIndex] = { + ...frames[originalFrameIndex], + fileName: resolved.file, + lineNumber: resolved.line1 ?? undefined, + columnNumber: resolved.column1 ?? undefined, + isSymbolicated: true, + }; + } + + return resolvedFrames; + } catch (_error) { + return frames; + } finally { + clearTimeout(timeout); + } +}; + +const extractServerFramesFromDebugStack = ( + rootFiber: Fiber, +): Map => { + const serverFramesByName = new Map(); + + traverseFiber( + rootFiber, + (currentFiber) => { + if (!hasDebugStack(currentFiber)) return false; + + const debugStack = currentFiber._debugStack?.stack; + if (!debugStack) return false; + + const ownerStack = formatOwnerStack(debugStack); + if (!ownerStack) return false; + + for (const frame of parseStack(ownerStack)) { + if (!frame.functionName || !frame.fileName) continue; + if (!isServerComponentUrl(frame.fileName)) continue; + if (serverFramesByName.has(frame.functionName)) continue; + + serverFramesByName.set(frame.functionName, { + ...frame, + isServer: true, + }); + } + return false; + }, + true, + ); + + return serverFramesByName; +}; + +const enrichServerFrameLocations = ( + rootFiber: Fiber, + frames: StackFrame[], +): StackFrame[] => { + const hasUnresolvedServerFrames = frames.some( + (frame) => frame.isServer && !frame.fileName && frame.functionName, + ); + if (!hasUnresolvedServerFrames) return frames; + + const serverFramesByName = extractServerFramesFromDebugStack(rootFiber); + if (serverFramesByName.size === 0) return frames; + + return frames.map((frame) => { + if (!frame.isServer || frame.fileName || !frame.functionName) return frame; + const resolved = serverFramesByName.get(frame.functionName); + if (!resolved) return frame; + return { + ...frame, + fileName: resolved.fileName, + lineNumber: resolved.lineNumber, + columnNumber: resolved.columnNumber, + }; + }); +}; + +export const getSelectionElementComponentName = ( + element: Element, +): string | null => { + if (!isInstrumentationActive()) return null; + const resolvedElement = findNearestFiberElement(element); + if (!resolvedElement) return null; + const fiber = getFiberFromHostInstance(resolvedElement); + if (!fiber) return null; + + let currentFiber = fiber.return; + while (currentFiber) { + if (isCompositeFiber(currentFiber)) { + const name = getDisplayName(currentFiber.type); + if (name && isUsefulComponentName(name)) { + return name; + } + } + currentFiber = currentFiber.return; + } + + return null; +}; + +const getFiberComponentRankByName = (element: Element): Map => { + const rankByName = new Map(); + if (!isInstrumentationActive()) return rankByName; + + const resolvedElement = findNearestFiberElement(element); + if (!resolvedElement) return rankByName; + const fiber = getFiberFromHostInstance(resolvedElement); + if (!fiber) return rankByName; + + let currentFiber = fiber.return; + let rank = 0; + while (currentFiber) { + if (isCompositeFiber(currentFiber)) { + const name = normalizeDisplayComponentName( + getDisplayName(currentFiber.type), + ); + if (name && !rankByName.has(name)) { + rankByName.set(name, rank); + } + rank += 1; + } + currentFiber = currentFiber.return; + } + + return rankByName; +}; + +const stackFrameCache = new WeakMap>(); + +const buildStackFramesForElement = async ( + nearestFiberElement: Element, +): Promise => { + const fiber = getFiberFromHostInstance(nearestFiberElement); + if (!fiber) { + return []; + } + + const buildFiberStackFrames = async (): Promise => { + const fallbackFiberStack = getFiberStack(fiber).slice(0, MAX_STACK_FRAMES); + const stackFrames: SelectionStackFrame[] = []; + + for (const stackFiber of fallbackFiberStack) { + const source = await getSource(stackFiber).catch(() => null); + stackFrames.push({ + functionName: getDisplayNameForFiber(stackFiber), + fileName: source?.fileName + ? normalizeSourceFilePath(source.fileName) + : null, + lineNumber: source?.lineNumber ?? null, + columnNumber: source?.columnNumber ?? null, + }); + } + + return stackFrames; + }; + + try { + const ownerStack = checkIsNextProject() + ? await symbolicateServerFrames( + enrichServerFrameLocations(fiber, await getOwnerStack(fiber)), + ) + : await getOwnerStack(fiber); + const ownerStackFrames = ownerStack + .slice(0, MAX_STACK_FRAMES) + .map(mapOwnerStackFrame); + const fiberStackFrames = await buildFiberStackFrames(); + const unresolvedOwnerFrameNames = new Set( + ownerStackFrames + .filter((stackFrame) => !stackFrame.fileName && stackFrame.functionName) + .map((stackFrame) => stackFrame.functionName as string), + ); + const seenFrameKeys = new Set( + ownerStackFrames.map( + (stackFrame) => + `${stackFrame.functionName ?? ''}:${stackFrame.fileName ?? ''}:${stackFrame.lineNumber ?? ''}:${stackFrame.columnNumber ?? ''}`, + ), + ); + const supplementalFiberFrames = fiberStackFrames.filter((stackFrame) => { + if ( + stackFrame.fileName && + isExternalSourceFrame(stackFrame.fileName) && + stackFrame.functionName && + unresolvedOwnerFrameNames.has(stackFrame.functionName) + ) { + return false; + } + + const frameKey = `${stackFrame.functionName ?? ''}:${stackFrame.fileName ?? ''}:${stackFrame.lineNumber ?? ''}:${stackFrame.columnNumber ?? ''}`; + if (seenFrameKeys.has(frameKey)) { + return false; + } + seenFrameKeys.add(frameKey); + return true; + }); + + return [...ownerStackFrames, ...supplementalFiberFrames].slice( + 0, + MAX_STACK_FRAMES, + ); + } catch (_error) { + return buildFiberStackFrames(); + } +}; + +const buildStackFrames = ( + nearestFiberElement: Element, +): Promise => { + const cachedStackFrames = stackFrameCache.get(nearestFiberElement); + if (cachedStackFrames) { + return cachedStackFrames; + } + + const stackFrames = buildStackFramesForElement(nearestFiberElement); + stackFrameCache.set(nearestFiberElement, stackFrames); + return stackFrames; +}; + +const resolveSourceFrames = ( + stackFrames: SelectionStackFrame[], + preferredComponentName: string | null, + componentRankByName: Map, +): SelectionResolvedSource[] => { + const resolvedSources: SelectionResolvedSource[] = []; + const seenSourceKeys = new Set(); + const sourceFrames = stackFrames.filter( + (stackFrame) => + stackFrame.fileName && isProjectSourceFrame(stackFrame.fileName), + ); + + const getFrameComponentName = (stackFrame: SelectionStackFrame) => + stackFrame.fileName + ? inferComponentNameFromSource( + stackFrame.fileName, + stackFrame.functionName, + ) + : null; + + const getFiberRank = (stackFrame: SelectionStackFrame): number => { + const componentName = getFrameComponentName(stackFrame); + if (!componentName) return Number.POSITIVE_INFINITY; + return componentRankByName.get(componentName) ?? Number.POSITIVE_INFINITY; + }; + + const sortedSourceFrames = [...sourceFrames].sort((left, right) => { + const leftRank = getFiberRank(left); + const rightRank = getFiberRank(right); + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + + const leftComponentName = getFrameComponentName(left); + const rightComponentName = getFrameComponentName(right); + const leftIsPreferred = leftComponentName === preferredComponentName; + const rightIsPreferred = rightComponentName === preferredComponentName; + if (leftIsPreferred !== rightIsPreferred) { + return leftIsPreferred ? -1 : 1; + } + + const leftIsSourceComponent = isSourceComponentName(left.functionName); + const rightIsSourceComponent = isSourceComponentName(right.functionName); + if (leftIsSourceComponent !== rightIsSourceComponent) { + return leftIsSourceComponent ? -1 : 1; + } + + return 0; + }); + + for (const stackFrame of sortedSourceFrames) { + if (!stackFrame.fileName) { + continue; + } + + const sourceKey = `${stackFrame.fileName}:${stackFrame.lineNumber ?? 0}:${stackFrame.columnNumber ?? 0}`; + if (seenSourceKeys.has(sourceKey)) { + continue; + } + + const componentName = inferComponentNameFromSource( + stackFrame.fileName, + stackFrame.functionName, + ); + seenSourceKeys.add(sourceKey); + resolvedSources.push({ + filePath: stackFrame.fileName, + lineNumber: stackFrame.lineNumber, + columnNumber: stackFrame.columnNumber, + componentName, + }); + + if (resolvedSources.length >= MAX_SOURCE_FRAMES) { + break; + } + } + + return resolvedSources; +}; + +const resolveExternalComponent = ( + stackFrames: SelectionStackFrame[], + selectedComponentName: string | null, + resolvedSources: SelectionResolvedSource[], +): SelectionExternalComponent | null => { + const selectedDisplayName = normalizeDisplayComponentName( + selectedComponentName, + ); + if (!selectedDisplayName) { + return null; + } + + const externalFrames = stackFrames.filter( + (stackFrame) => + stackFrame.fileName && + isExternalSourceFrame(stackFrame.fileName) && + isUsefulComponentName(stackFrame.functionName), + ); + if (externalFrames.length === 0) { + return null; + } + + const selectedExternalFrame = externalFrames.find( + (stackFrame) => + normalizeDisplayComponentName(stackFrame.functionName) === + selectedDisplayName, + ); + if (!selectedExternalFrame?.fileName) { + return null; + } + + return { + componentName: + normalizeDisplayComponentName(selectedExternalFrame.functionName) || + selectedDisplayName, + packageName: extractPackageNameFromFilePath(selectedExternalFrame.fileName), + filePath: normalizeSourceFilePath(selectedExternalFrame.fileName), + usedBy: resolvedSources[0] ?? null, + }; +}; + +const formatTraceFrameKey = ( + frame: Pick< + SelectionSourceTraceFrame, + 'kind' | 'componentName' | 'filePath' | 'lineNumber' | 'columnNumber' + >, +): string => + `${frame.kind}:${frame.componentName ?? ''}:${frame.filePath}:${frame.lineNumber ?? ''}:${frame.columnNumber ?? ''}`; + +const mapStackFrameToTraceFrame = ( + stackFrame: SelectionStackFrame, +): SelectionSourceTraceFrame | null => { + if (!stackFrame.fileName) return null; + + if (isExternalSourceFrame(stackFrame.fileName)) { + return { + kind: 'external', + componentName: + normalizeDisplayComponentName(stackFrame.functionName) ?? null, + packageName: extractPackageNameFromFilePath(stackFrame.fileName), + filePath: normalizeSourceFilePath(stackFrame.fileName), + lineNumber: stackFrame.lineNumber, + columnNumber: stackFrame.columnNumber, + }; + } + + if (!isProjectSourceFrame(stackFrame.fileName)) { + return null; + } + + const filePath = normalizeSourceFilePath(stackFrame.fileName); + return { + kind: 'project', + componentName: inferComponentNameFromSource( + filePath, + stackFrame.functionName, + ), + packageName: null, + filePath, + lineNumber: stackFrame.lineNumber, + columnNumber: stackFrame.columnNumber, + }; +}; + +const buildSourceTrace = ( + stackFrames: SelectionStackFrame[], + resolvedSources: SelectionResolvedSource[], +): SelectionSourceTraceFrame[] => { + const traceFrames: SelectionSourceTraceFrame[] = []; + const seenTraceFrameKeys = new Set(); + let previousExternalPackageName: string | null = null; + + const addTraceFrame = (frame: SelectionSourceTraceFrame | null) => { + if (!frame?.filePath) return; + + if ( + frame.kind === 'external' && + frame.packageName && + frame.packageName === previousExternalPackageName + ) { + return; + } + + const traceFrameKey = formatTraceFrameKey(frame); + if (seenTraceFrameKeys.has(traceFrameKey)) return; + + seenTraceFrameKeys.add(traceFrameKey); + traceFrames.push(frame); + previousExternalPackageName = + frame.kind === 'external' ? frame.packageName : null; + }; + + for (const stackFrame of stackFrames) { + addTraceFrame(mapStackFrameToTraceFrame(stackFrame)); + if (traceFrames.length >= MAX_SOURCE_FRAMES) { + break; + } + } + + const hasProjectFrame = traceFrames.some( + (traceFrame) => traceFrame.kind === 'project', + ); + + if (!hasProjectFrame) { + for (const resolvedSource of resolvedSources) { + addTraceFrame({ + kind: 'project', + componentName: resolvedSource.componentName, + packageName: null, + filePath: resolvedSource.filePath, + lineNumber: resolvedSource.lineNumber, + columnNumber: resolvedSource.columnNumber, + }); + if (traceFrames.length >= MAX_SOURCE_FRAMES) { + break; + } + } + } + + return traceFrames; +}; + +const getComponentName = ( + stackFrames: SelectionStackFrame[], +): string | null => { + for (const stackFrame of stackFrames) { + if ( + stackFrame.fileName && + isProjectSourceFrame(stackFrame.fileName) && + isSourceComponentName(stackFrame.functionName) + ) { + return stackFrame.functionName; + } + } + + for (const stackFrame of stackFrames) { + if (isUsefulComponentName(stackFrame.functionName)) { + return stackFrame.functionName; + } + } + + return null; +}; + +export const buildSelectionContextForElement = async ( + selectedElement: Element, +): Promise => { + const nearestFiberElement = findNearestFiberElement(selectedElement); + const stackFrames = nearestFiberElement + ? await buildStackFrames(nearestFiberElement) + : []; + const componentRankByName = getFiberComponentRankByName(selectedElement); + const displayComponentName = + getSelectionElementComponentName(selectedElement); + const stackComponentName = getComponentName(stackFrames); + const fallbackComponentName = stackComponentName || displayComponentName; + const resolvedSources = resolveSourceFrames( + stackFrames, + fallbackComponentName, + componentRankByName, + ); + const sourceComponentName = + resolvedSources.find((source) => source.componentName)?.componentName ?? + null; + const componentName = + sourceComponentName || stackComponentName || displayComponentName; + const externalComponent = resolveExternalComponent( + stackFrames, + componentName, + resolvedSources, + ); + const sourceTrace = buildSourceTrace(stackFrames, resolvedSources); + const selectionContext = { + domPreview: getDomPreview(selectedElement), + sourcePreview: null, + selector: createElementSelector(selectedElement), + componentName, + externalComponent, + stackFrames, + resolvedSources, + sourceTrace, + sourceSnippets: [], + capturedAt: Date.now(), + }; + + return { + ...selectionContext, + sourcePreview: buildSelectionSourcePreview(selectionContext), + }; +}; diff --git a/packages/core/src/core/tools/selection_dom.ts b/packages/core/src/core/tools/selection_dom.ts new file mode 100644 index 0000000..004c33b --- /dev/null +++ b/packages/core/src/core/tools/selection_dom.ts @@ -0,0 +1,138 @@ +const VIEWPORT_COVERAGE_THRESHOLD = 0.9; +const OVERLAY_Z_INDEX_THRESHOLD = 1000; +const DEV_TOOLS_OVERLAY_Z_INDEX_THRESHOLD = 2147483600; +const ELEMENT_POSITION_CACHE_DISTANCE_THRESHOLD_PX = 2; +const ELEMENT_POSITION_THROTTLE_MS = 16; + +interface PositionCache { + clientX: number; + clientY: number; + element: Element | null; + timestamp: number; +} + +let cache: PositionCache | null = null; + +const isRootElement = (element: Element): boolean => { + const tagName = element.tagName.toLowerCase(); + return tagName === 'html' || tagName === 'body'; +}; + +const hasTransparentBackground = ( + computedStyle: CSSStyleDeclaration, +): boolean => { + const backgroundColor = computedStyle.backgroundColor; + return ( + backgroundColor === 'transparent' || backgroundColor === 'rgba(0, 0, 0, 0)' + ); +}; + +const isDevToolsOverlay = (computedStyle: CSSStyleDeclaration): boolean => { + const zIndex = Number.parseInt(computedStyle.zIndex, 10); + return ( + computedStyle.pointerEvents === 'none' && + computedStyle.position === 'fixed' && + !Number.isNaN(zIndex) && + zIndex >= DEV_TOOLS_OVERLAY_Z_INDEX_THRESHOLD + ); +}; + +const isFullViewportOverlay = ( + element: Element, + computedStyle: CSSStyleDeclaration, +): boolean => { + const position = computedStyle.position; + if (position !== 'fixed' && position !== 'absolute') return false; + + const coversViewport = + element.clientWidth / window.innerWidth >= VIEWPORT_COVERAGE_THRESHOLD && + element.clientHeight / window.innerHeight >= VIEWPORT_COVERAGE_THRESHOLD; + if (!coversViewport) return false; + + if (hasTransparentBackground(computedStyle)) return true; + if (Number.parseFloat(computedStyle.opacity) < 0.1) return true; + + const zIndex = Number.parseInt(computedStyle.zIndex, 10); + return !Number.isNaN(zIndex) && zIndex > OVERLAY_Z_INDEX_THRESHOLD; +}; + +const isVisibleElement = ( + element: Element, + computedStyle: CSSStyleDeclaration, +): boolean => + computedStyle.display !== 'none' && + computedStyle.visibility !== 'hidden' && + computedStyle.opacity !== '0' && + element.getClientRects().length > 0; + +const isWithinThreshold = ( + x1: number, + y1: number, + x2: number, + y2: number, +): boolean => + Math.abs(x1 - x2) <= ELEMENT_POSITION_CACHE_DISTANCE_THRESHOLD_PX && + Math.abs(y1 - y2) <= ELEMENT_POSITION_CACHE_DISTANCE_THRESHOLD_PX; + +export const isSelectableElement = ( + element: Element, + isOverlayElement: (element: Element) => boolean, +): boolean => { + if (isRootElement(element)) return false; + if (isOverlayElement(element)) return false; + + const computedStyle = window.getComputedStyle(element); + if (!isVisibleElement(element, computedStyle)) return false; + if (isDevToolsOverlay(computedStyle)) return false; + if (isFullViewportOverlay(element, computedStyle)) return false; + + return true; +}; + +export const getSelectableElementAtPosition = ( + clientX: number, + clientY: number, + isOverlayElement: (element: Element) => boolean, +): Element | null => { + if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; + + const now = performance.now(); + if (cache) { + const isPositionClose = isWithinThreshold( + clientX, + clientY, + cache.clientX, + cache.clientY, + ); + const isWithinThrottle = + now - cache.timestamp < ELEMENT_POSITION_THROTTLE_MS; + + if (isPositionClose || isWithinThrottle) { + return cache.element; + } + } + + let result: Element | null = null; + const topElement = document.elementFromPoint(clientX, clientY); + if (topElement && isSelectableElement(topElement, isOverlayElement)) { + result = topElement; + } else { + const elementsAtPoint = document.elementsFromPoint(clientX, clientY); + for (const candidateElement of elementsAtPoint) { + if ( + candidateElement !== topElement && + isSelectableElement(candidateElement, isOverlayElement) + ) { + result = candidateElement; + break; + } + } + } + + cache = { clientX, clientY, element: result, timestamp: now }; + return result; +}; + +export const clearSelectableElementCache = (): void => { + cache = null; +}; diff --git a/packages/core/src/core/tools/selection_selector.ts b/packages/core/src/core/tools/selection_selector.ts new file mode 100644 index 0000000..d77a452 --- /dev/null +++ b/packages/core/src/core/tools/selection_selector.ts @@ -0,0 +1,363 @@ +const FINDER_TIMEOUT_MS = 200; +const MAX_SELECTOR_COMBINATIONS = 10000; +const SELECTOR_ATTR_VALUE_MAX_LENGTH_CHARS = 120; + +interface SelectorNode { + name: string; + penalty: number; +} + +const PREFERRED_SELECTOR_ATTRIBUTE_NAMES = new Set([ + 'data-testid', + 'data-test-id', + 'data-test', + 'data-cy', + 'data-qa', + 'aria-label', + 'role', + 'name', + 'title', + 'alt', +]); + +const ACCEPTED_ATTR_NAMES = new Set([ + 'role', + 'name', + 'aria-label', + 'rel', + 'href', +]); + +const escapeCssIdentifier = (value: string): string => { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(value); + } + return value.replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`); +}; + +const isWordLike = (text: string): boolean => { + if (!/^[a-z\-]{3,}$/i.test(text)) return false; + const segments = text.split(/-|[A-Z]/); + for (const segment of segments) { + if (segment.length <= 2) return false; + if (/[^aeiou]{4,}/i.test(segment)) return false; + } + return true; +}; + +const isPreferredAttributeValueSafe = (value: string): boolean => + value.length > 0 && value.length <= SELECTOR_ATTR_VALUE_MAX_LENGTH_CHARS; + +const isAcceptedAttr = ( + attributeName: string, + attributeValue: string, +): boolean => { + const nameIsAccepted = + ACCEPTED_ATTR_NAMES.has(attributeName) || + (attributeName.startsWith('data-') && isWordLike(attributeName)); + const valueIsAccepted = + (isWordLike(attributeValue) && + attributeValue.length < SELECTOR_ATTR_VALUE_MAX_LENGTH_CHARS) || + (attributeValue.startsWith('#') && isWordLike(attributeValue.slice(1))); + return nameIsAccepted && valueIsAccepted; +}; + +const getFinderRoot = (element: Element): Element => + element.ownerDocument.body ?? element.ownerDocument.documentElement; + +const isSelectorUniqueForElement = ( + element: Element, + selector: string, +): boolean => { + try { + const matchingElements = element.ownerDocument.querySelectorAll(selector); + return matchingElements.length === 1 && matchingElements[0] === element; + } catch (_error) { + return false; + } +}; + +const createFastElementSelector = (element: Element): string | null => { + if (element instanceof HTMLElement && element.id) { + const idSelector = `#${escapeCssIdentifier(element.id)}`; + if (isSelectorUniqueForElement(element, idSelector)) return idSelector; + } + + for (const attributeName of PREFERRED_SELECTOR_ATTRIBUTE_NAMES) { + const attributeValue = element.getAttribute(attributeName); + if (!attributeValue) continue; + if (!isPreferredAttributeValueSafe(attributeValue)) continue; + + const quotedValue = JSON.stringify(attributeValue); + const attributeOnlySelector = `[${attributeName}=${quotedValue}]`; + if (isSelectorUniqueForElement(element, attributeOnlySelector)) { + return attributeOnlySelector; + } + + const tagSelector = `${element.tagName.toLowerCase()}${attributeOnlySelector}`; + if (isSelectorUniqueForElement(element, tagSelector)) { + return tagSelector; + } + } + + return null; +}; + +const getChildIndex = ( + element: Element, + filterTagName?: string, +): number | undefined => { + const parentNode = element.parentNode; + if (!parentNode) return undefined; + + let sibling = parentNode.firstChild; + if (!sibling) return undefined; + + let position = 0; + while (sibling) { + if ( + sibling.nodeType === Node.ELEMENT_NODE && + (filterTagName === undefined || + (sibling as Element).tagName.toLowerCase() === filterTagName) + ) { + position++; + } + if (sibling === element) break; + sibling = sibling.nextSibling; + } + return position; +}; + +const formatNthChild = (tagName: string, childPosition: number): string => + tagName === 'html' ? 'html' : `${tagName}:nth-child(${childPosition})`; + +const formatNthOfType = (tagName: string, typePosition: number): string => + tagName === 'html' ? 'html' : `${tagName}:nth-of-type(${typePosition})`; + +const collectCandidateNodes = (element: Element): SelectorNode[] => { + const candidates: SelectorNode[] = []; + const elementId = element.getAttribute('id'); + const elementTagName = element.tagName.toLowerCase(); + + if (elementId && isWordLike(elementId)) { + candidates.push({ + name: `#${escapeCssIdentifier(elementId)}`, + penalty: 0, + }); + } + + for (const className of Array.from(element.classList)) { + if (isWordLike(className)) { + candidates.push({ + name: `.${escapeCssIdentifier(className)}`, + penalty: 1, + }); + } + } + + for (const attribute of Array.from(element.attributes)) { + if (isAcceptedAttr(attribute.name, attribute.value)) { + candidates.push({ + name: `[${escapeCssIdentifier(attribute.name)}="${escapeCssIdentifier(attribute.value)}"]`, + penalty: 2, + }); + } + } + + candidates.push({ name: elementTagName, penalty: 5 }); + + const typePosition = getChildIndex(element, elementTagName); + if (typePosition !== undefined) { + candidates.push({ + name: formatNthOfType(elementTagName, typePosition), + penalty: 10, + }); + } + + const childPosition = getChildIndex(element); + if (childPosition !== undefined) { + candidates.push({ + name: formatNthChild(elementTagName, childPosition), + penalty: 50, + }); + } + + return candidates; +}; + +const buildSelectorString = (path: SelectorNode[]): string => { + let result = path[0].name; + for (let index = 1; index < path.length; index++) { + result = `${path[index].name} > ${result}`; + } + return result; +}; + +const collectCombinations = ( + stack: SelectorNode[][], + budget = MAX_SELECTOR_COMBINATIONS, + currentPath: SelectorNode[] = [], +): SelectorNode[][] => { + if (budget <= 0) return []; + if (stack.length === 0) return [currentPath]; + + const results: SelectorNode[][] = []; + for (const selectorNode of stack[0]) { + const remainingBudget = budget - results.length; + if (remainingBudget <= 0) break; + results.push( + ...collectCombinations(stack.slice(1), remainingBudget, [ + ...currentPath, + selectorNode, + ]), + ); + } + return results; +}; + +const calculatePenalty = (path: SelectorNode[]): number => + path.reduce((total, selectorNode) => total + selectorNode.penalty, 0); + +const comparePenalty = (pathA: SelectorNode[], pathB: SelectorNode[]): number => + calculatePenalty(pathA) - calculatePenalty(pathB); + +const resolveRootDocument = ( + rootNode: Element | Document, + targetElement: Element, +): Element | Document => { + const attachedRoot = targetElement.getRootNode?.(); + if (attachedRoot instanceof ShadowRoot) { + return attachedRoot as unknown as Document; + } + if (rootNode.nodeType === Node.DOCUMENT_NODE) return rootNode; + return (rootNode as Element).ownerDocument; +}; + +const isSelectorUnique = ( + selectorPath: SelectorNode[], + rootDocument: Element | Document, +): boolean => + rootDocument.querySelectorAll(buildSelectorString(selectorPath)).length === 1; + +const buildFallbackPath = ( + targetElement: Element, + rootDocument: Element | Document, +): SelectorNode[] | undefined => { + let currentElement: Element | null = targetElement; + const path: SelectorNode[] = []; + + while (currentElement && currentElement !== rootDocument) { + const currentTagName = currentElement.tagName.toLowerCase(); + const typePosition = getChildIndex(currentElement, currentTagName); + if (typePosition === undefined) return undefined; + + path.push({ + name: formatNthOfType(currentTagName, typePosition), + penalty: 10, + }); + currentElement = currentElement.parentElement; + } + + return isSelectorUnique(path, rootDocument) ? path : undefined; +}; + +const findUniqueSelector = ( + targetElement: Element, + root: Element | Document, +): string | null => { + if (targetElement.nodeType !== Node.ELEMENT_NODE) return null; + if (targetElement.tagName.toLowerCase() === 'html') return 'html'; + + const rootDocument = resolveRootDocument(root, targetElement); + const startTime = Date.now(); + const ancestorStack: SelectorNode[][] = []; + let currentElement: Element | null = targetElement; + let depth = 0; + let foundPath: SelectorNode[] | undefined; + + while (currentElement && currentElement !== rootDocument && !foundPath) { + ancestorStack.push(collectCandidateNodes(currentElement)); + currentElement = currentElement.parentElement; + depth++; + + if (depth >= 3) { + const candidatePaths = collectCombinations(ancestorStack); + candidatePaths.sort(comparePenalty); + + for (const candidatePath of candidatePaths) { + if (Date.now() - startTime > FINDER_TIMEOUT_MS) { + const fallbackPath = buildFallbackPath(targetElement, rootDocument); + return fallbackPath ? buildSelectorString(fallbackPath) : null; + } + if (isSelectorUnique(candidatePath, rootDocument)) { + foundPath = candidatePath; + break; + } + } + } + } + + if (!foundPath && depth < 3) { + const remainingPaths = collectCombinations(ancestorStack); + remainingPaths.sort(comparePenalty); + for (const candidatePath of remainingPaths) { + if (Date.now() - startTime > FINDER_TIMEOUT_MS) break; + if (isSelectorUnique(candidatePath, rootDocument)) { + foundPath = candidatePath; + break; + } + } + } + + return foundPath ? buildSelectorString(foundPath) : null; +}; + +const createNthChildSelector = (element: Element): string => { + const segments: string[] = []; + const root = getFinderRoot(element); + let currentElement: Element | null = element; + + while (currentElement) { + if (currentElement instanceof HTMLElement && currentElement.id) { + segments.unshift(`#${escapeCssIdentifier(currentElement.id)}`); + break; + } + + const parentElement = currentElement.parentElement; + if (!parentElement) { + segments.unshift(currentElement.tagName.toLowerCase()); + break; + } + + const siblings = Array.from(parentElement.children); + const siblingIndex = siblings.indexOf(currentElement); + const nthChild = siblingIndex >= 0 ? siblingIndex + 1 : 1; + segments.unshift( + `${currentElement.tagName.toLowerCase()}:nth-child(${nthChild})`, + ); + + if (parentElement === root) { + segments.unshift(root.tagName.toLowerCase()); + break; + } + + currentElement = parentElement; + } + + return segments.join(' > '); +}; + +export const createElementSelector = (element: Element): string => { + const fastSelector = createFastElementSelector(element); + if (fastSelector) return fastSelector; + + try { + const selector = findUniqueSelector(element, getFinderRoot(element)); + if (selector) return selector; + } catch (_error) { + // Unusual DOMs can throw while querying selectors. The nth-child path is slower + // to read, but deterministic and still points back to the selected element. + } + + return createNthChildSelector(element); +}; diff --git a/packages/core/src/core/tools/selection_toolkit.ts b/packages/core/src/core/tools/selection_toolkit.ts new file mode 100644 index 0000000..498e023 --- /dev/null +++ b/packages/core/src/core/tools/selection_toolkit.ts @@ -0,0 +1,3348 @@ +import { __AGENTIC_REACT_CONFIG__, target } from '../../shared/const.js'; +import { DEFAULT_TOOLKIT_ICON_DATA_URL } from '../../shared/default_toolkit_icon.js'; +import { SOURCE_LOOKUP_PATH } from '../../shared/protocol.js'; +import { + buildSelectionSourcePreview, + buildWebContextText, +} from '../../shared/selection_context_format.js'; +import { + buildBrowserSourceCandidates, + isAllowedProjectSourcePath, + normalizeSourceRoot, + toAbsoluteSourcePath, +} from '../../shared/source_path.js'; +import type { + SelectionContext, + SelectionResolvedSource, + ToolkitConfig, + ToolkitPosition, + ToolkitTuningModalStyle, + ToolkitTuningModalStyleSlot, + TuningModalActions, + TuningModalContext, + TuningModalExtension, + TuningModalExtensionCleanup, +} from '../../shared/types.js'; +import { + buildSelectionContextForElement, + getSelectionElementComponentName, +} from './selection_context.js'; +import { + clearSelectableElementCache, + getSelectableElementAtPosition, +} from './selection_dom.js'; + +interface ToolkitRuntimeOptions { + initialConfig?: ToolkitConfig; +} + +interface ToolkitRuntimeResult { + showToolkit: () => void; + hideToolkit: () => void; + setToolkitConfig: (config: Partial) => ToolkitConfig; + enterSelectionMode: () => void; + exitSelectionMode: () => void; + setSelectionMode: (enabled: boolean) => void; + getLastSelectionContext: () => SelectionContext | null; + copyLastSelectionContext: (format?: 'text' | 'json') => Promise<{ + success: boolean; + copied: boolean; + format: 'text' | 'json'; + context?: SelectionContext; + error?: string; + }>; + registerTuningModalExtension: (extension: TuningModalExtension) => () => void; +} + +const DEFAULT_TOOLKIT_CONFIG: Required> & { + iconUrl: string | null; +} = { + enabled: true, + defaultVisible: true, + defaultExpanded: false, + position: 'bottom-right', + offset: { + x: 20, + y: 20, + }, + accentColor: '#111827', + zIndex: 2147483000, + iconUrl: DEFAULT_TOOLKIT_ICON_DATA_URL, + tuningModal: {}, +}; + +const DEFAULT_SNIPPET_CONTEXT_LINES = 8; +const DEFAULT_MAX_SNIPPET_FILES = 3; +const LAUNCHER_SIZE = 58; +const SELECTION_CONFIRMATION_MS = 1100; +const SELECTION_STYLE_ID = 'agentic-react-selection-styles'; +const CONTRAST_LIGHT = 'rgba(255, 255, 255, 0.98)'; +const CONTRAST_DARK = 'rgba(15, 23, 42, 0.98)'; +const SELECTED_LABEL_MAX_LENGTH = 96; +const TUNING_PANEL_MARGIN = 12; +const TUNING_PANEL_GAP = 8; +const TUNING_FONT_WEIGHT_OPTIONS = [ + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', +]; +const TUNING_FONT_FAMILY_OPTIONS = [ + 'inherit', + 'system-ui', + 'Inter', + 'Arial', + 'Georgia', + 'Times New Roman', + 'monospace', +]; +const TUNING_DISPLAY_OPTIONS = [ + 'block', + 'inline-block', + 'flex', + 'inline-flex', + 'grid', + 'inline-grid', +]; +const TUNING_FLEX_DIRECTION_OPTIONS = [ + 'row', + 'row-reverse', + 'column', + 'column-reverse', +]; +const TUNING_JUSTIFY_OPTIONS = [ + 'flex-start', + 'center', + 'flex-end', + 'space-between', + 'space-around', + 'space-evenly', +]; +const TUNING_ALIGN_OPTIONS = [ + 'stretch', + 'flex-start', + 'center', + 'flex-end', + 'baseline', +]; + +interface RgbaColor { + r: number; + g: number; + b: number; + a: number; +} + +interface OverlayActionElements { + actionsElement: HTMLDivElement; + tuneButtonElement: HTMLButtonElement; + deleteButtonElement: HTMLButtonElement; +} + +interface TuningSession { + anchorElement: HTMLElement; + element: Element; + selectionContext: SelectionContext; + setSelectionContext: (selectionContext: SelectionContext) => void; +} + +const mergeToolkitConfig = ( + config: ToolkitConfig | undefined, + nextConfig: Partial | undefined, +): Required> & { iconUrl: string | null } => { + const baseConfig = { + ...DEFAULT_TOOLKIT_CONFIG, + ...(config || {}), + iconUrl: + config?.iconUrl !== undefined + ? config.iconUrl + : DEFAULT_TOOLKIT_CONFIG.iconUrl, + }; + + if (!nextConfig) { + return { + ...baseConfig, + offset: { + ...DEFAULT_TOOLKIT_CONFIG.offset, + ...(baseConfig.offset || {}), + }, + tuningModal: { + ...DEFAULT_TOOLKIT_CONFIG.tuningModal, + ...(baseConfig.tuningModal || {}), + classNames: { + ...(baseConfig.tuningModal?.classNames || {}), + }, + styles: { + ...(baseConfig.tuningModal?.styles || {}), + }, + tokens: { + ...(baseConfig.tuningModal?.tokens || {}), + }, + }, + }; + } + + return { + ...baseConfig, + ...nextConfig, + iconUrl: + nextConfig.iconUrl !== undefined + ? nextConfig.iconUrl + : baseConfig.iconUrl, + offset: { + ...DEFAULT_TOOLKIT_CONFIG.offset, + ...(baseConfig.offset || {}), + ...(nextConfig.offset || {}), + }, + tuningModal: { + ...DEFAULT_TOOLKIT_CONFIG.tuningModal, + ...(baseConfig.tuningModal || {}), + ...(nextConfig.tuningModal || {}), + classNames: { + ...(baseConfig.tuningModal?.classNames || {}), + ...(nextConfig.tuningModal?.classNames || {}), + }, + styles: { + ...(baseConfig.tuningModal?.styles || {}), + ...(nextConfig.tuningModal?.styles || {}), + }, + tokens: { + ...(baseConfig.tuningModal?.tokens || {}), + ...(nextConfig.tuningModal?.tokens || {}), + }, + }, + }; +}; + +const setPosition = ( + element: HTMLElement, + position: ToolkitPosition, + xOffset: number, + yOffset: number, +) => { + element.style.top = ''; + element.style.right = ''; + element.style.bottom = ''; + element.style.left = ''; + + if (position === 'top-left') { + element.style.top = `${yOffset}px`; + element.style.left = `${xOffset}px`; + return; + } + + if (position === 'top-right') { + element.style.top = `${yOffset}px`; + element.style.right = `${xOffset}px`; + return; + } + + if (position === 'bottom-left') { + element.style.bottom = `${yOffset}px`; + element.style.left = `${xOffset}px`; + return; + } + + element.style.bottom = `${yOffset}px`; + element.style.right = `${xOffset}px`; +}; + +const toKebabCase = (value: string): string => + value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + +const getTuningTokenName = (tokenName: string): string => + `--agentic-react-tuning-${toKebabCase(tokenName)}`; + +const applyStyleDeclaration = ( + element: HTMLElement, + styleDeclaration: ToolkitTuningModalStyle | undefined, +) => { + if (!styleDeclaration) return; + + for (const [propertyName, propertyValue] of Object.entries( + styleDeclaration, + )) { + if (propertyName.startsWith('--') || propertyName.includes('-')) { + element.style.setProperty(propertyName, String(propertyValue)); + } else { + (element.style as CSSStyleDeclaration & Record)[ + propertyName + ] = String(propertyValue); + } + } +}; + +const parseCssColor = (color: string): RgbaColor | null => { + const normalizedColor = color.trim().toLowerCase(); + if ( + !normalizedColor || + normalizedColor === 'transparent' || + normalizedColor === 'currentcolor' + ) { + return null; + } + + const colorMatch = normalizedColor.match(/^rgba?\((.+)\)$/); + if (!colorMatch) { + return null; + } + + const colorParts = colorMatch[1] + .replace(/\s*\/\s*/, ' ') + .split(/[,\s]+/) + .filter(Boolean); + + if (colorParts.length < 3) { + return null; + } + + const parseChannel = (channel: string): number | null => { + if (channel.endsWith('%')) { + const percentValue = Number.parseFloat(channel); + return Number.isFinite(percentValue) + ? Math.round((Math.max(0, Math.min(100, percentValue)) / 100) * 255) + : null; + } + + const value = Number.parseFloat(channel); + return Number.isFinite(value) ? Math.max(0, Math.min(255, value)) : null; + }; + + const parseAlpha = (alpha: string | undefined): number => { + if (!alpha) return 1; + + if (alpha.endsWith('%')) { + const percentValue = Number.parseFloat(alpha); + return Number.isFinite(percentValue) + ? Math.max(0, Math.min(1, percentValue / 100)) + : 1; + } + + const value = Number.parseFloat(alpha); + return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1; + }; + + const r = parseChannel(colorParts[0]); + const g = parseChannel(colorParts[1]); + const b = parseChannel(colorParts[2]); + + if (r === null || g === null || b === null) { + return null; + } + + return { + r, + g, + b, + a: parseAlpha(colorParts[3]), + }; +}; + +const getRelativeLuminance = ({ r, g, b }: RgbaColor): number => { + const toLinearChannel = (channel: number): number => { + const normalizedChannel = channel / 255; + return normalizedChannel <= 0.03928 + ? normalizedChannel / 12.92 + : ((normalizedChannel + 0.055) / 1.055) ** 2.4; + }; + + return ( + 0.2126 * toLinearChannel(r) + + 0.7152 * toLinearChannel(g) + + 0.0722 * toLinearChannel(b) + ); +}; + +const getEffectiveBackgroundColor = (element: Element): RgbaColor | null => { + let currentElement: Element | null = element; + + while (currentElement) { + const computedStyle = window.getComputedStyle(currentElement); + const backgroundColor = parseCssColor(computedStyle.backgroundColor); + if (backgroundColor && backgroundColor.a > 0.05) { + return backgroundColor; + } + + currentElement = currentElement.parentElement; + } + + return parseCssColor(window.getComputedStyle(document.body).backgroundColor); +}; + +const applyHoverContrastForElement = ( + hoverElement: HTMLElement, + element: Element, +) => { + const backgroundColor = getEffectiveBackgroundColor(element); + const isDarkSurface = backgroundColor + ? getRelativeLuminance(backgroundColor) < 0.46 + : false; + + hoverElement.style.setProperty( + '--agentic-react-hover-inner', + isDarkSurface ? CONTRAST_LIGHT : CONTRAST_DARK, + ); + hoverElement.style.setProperty( + '--agentic-react-hover-outer', + isDarkSurface ? CONTRAST_DARK : CONTRAST_LIGHT, + ); + hoverElement.style.setProperty( + '--agentic-react-hover-fill', + isDarkSurface ? 'rgba(255, 255, 255, 0.14)' : 'rgba(15, 23, 42, 0.08)', + ); +}; + +const normalizeElementLabel = (value: string | null | undefined): string => { + if (!value) return ''; + return value.replace(/\s+/g, ' ').trim(); +}; + +const truncateElementLabel = ( + value: string, + maxLength = SELECTED_LABEL_MAX_LENGTH, +): string => { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 1).trimEnd()}...`; +}; + +const getLabelledByText = (element: Element): string => { + const labelledBy = element.getAttribute('aria-labelledby'); + if (!labelledBy) return ''; + + const labelText = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent) + .filter(Boolean) + .join(' '); + return normalizeElementLabel(labelText); +}; + +const getAssociatedFormLabelText = (element: Element): string => { + if ( + element instanceof HTMLInputElement || + element instanceof HTMLSelectElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLButtonElement + ) { + const labels = Array.from(element.labels ?? []); + return normalizeElementLabel( + labels.map((labelElement) => labelElement.textContent).join(' '), + ); + } + + return ''; +}; + +const getMeaningfulVisibleText = (element: Element): string => { + const textContent = normalizeElementLabel(element.textContent); + if (!textContent) return ''; + + const repeatedWhitespaceText = textContent.replace(/\s+/g, ''); + if (repeatedWhitespaceText.length < 2) return ''; + return textContent; +}; + +const formatDomElementLabel = (element: Element, label: string): string => { + const tagName = element.tagName.toLowerCase(); + if (!label) return tagName; + + if (/^(button|a|input|select|textarea|img)$/i.test(tagName)) { + return `${tagName}: ${label}`; + } + + return label; +}; + +const formatSourceLocationLabel = ( + selectionContext?: SelectionContext | null, +): string => { + const source = + selectionContext?.resolvedSources[0] ?? + selectionContext?.externalComponent?.usedBy ?? + selectionContext?.sourceTrace.find((frame) => frame.kind === 'project'); + if (!source?.filePath) return ''; + + const fileName = source.filePath.split('/').pop() || source.filePath; + return source.lineNumber ? `${fileName}:${source.lineNumber}` : fileName; +}; + +const formatComponentLocationLabel = ( + componentName?: string | null, + sourceLocationLabel?: string | null, +): string => { + if (!componentName) return sourceLocationLabel || ''; + + const componentLabel = `<${componentName}>`; + return sourceLocationLabel + ? `${componentLabel} in ${sourceLocationLabel}` + : componentLabel; +}; + +const buildTuningTargetLabel = (selectionContext: SelectionContext): string => + formatComponentLocationLabel( + selectionContext.componentName || + selectionContext.externalComponent?.componentName, + formatSourceLocationLabel(selectionContext), + ) || + selectionContext.selector || + 'the selected element'; + +const clampNumber = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)); + +const rgbStringToHex = (colorValue: string): string => { + const match = colorValue.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/i); + if (!match) return '#111827'; + + return `#${[match[1], match[2], match[3]] + .map((part) => + clampNumber(Number(part), 0, 255).toString(16).padStart(2, '0'), + ) + .join('')}`; +}; + +const hexToRgbString = (hexValue: string): string => { + const normalizedHex = hexValue.replace('#', '').trim(); + if (!/^[0-9a-f]{6}$/i.test(normalizedHex)) return 'rgb(17, 24, 39)'; + + const red = Number.parseInt(normalizedHex.slice(0, 2), 16); + const green = Number.parseInt(normalizedHex.slice(2, 4), 16); + const blue = Number.parseInt(normalizedHex.slice(4, 6), 16); + return `rgb(${red}, ${green}, ${blue})`; +}; + +const formatAlpha = (alphaValue: number): string => + Number(alphaValue.toFixed(2)).toString(); + +const formatRgbaColor = ({ r, g, b, a }: RgbaColor): string => + `rgba(${r}, ${g}, ${b}, ${formatAlpha(a)})`; + +const cssColorToRgbaString = ( + colorValue: string, + fallbackColor: RgbaColor = { r: 0, g: 0, b: 0, a: 0 }, +): string => formatRgbaColor(parseCssColor(colorValue) || fallbackColor); + +const hexToRgbaString = (hexValue: string): string => { + const rgbColor = parseCssColor(hexToRgbString(hexValue)); + return rgbColor ? formatRgbaColor(rgbColor) : 'rgba(17, 24, 39, 1)'; +}; + +const hasTuneableText = (element: Element): boolean => { + const tagName = element.tagName.toLowerCase(); + if ( + /^(a|button|input|label|legend|option|p|span|strong|em|small|textarea|h[1-6])$/.test( + tagName, + ) + ) { + return true; + } + + if (getMeaningfulVisibleText(element)) { + return true; + } + + return Array.from(element.children).some((childElement) => + hasTuneableText(childElement), + ); +}; + +const hasTuneableLayout = ( + element: Element, + computedStyle: CSSStyleDeclaration, +) => { + const tagName = element.tagName.toLowerCase(); + if (/^(flex|inline-flex|grid|inline-grid)$/.test(computedStyle.display)) { + return true; + } + + if ( + /^(article|aside|body|div|fieldset|footer|form|header|label|li|main|nav|ol|section|table|tbody|td|tfoot|th|thead|tr|ul)$/.test( + tagName, + ) + ) { + return true; + } + + return element.children.length > 0; +}; + +const normalizeCssNumber = (value: string, fallbackValue: number): number => { + const parsedValue = Number.parseFloat(value); + return Number.isFinite(parsedValue) ? parsedValue : fallbackValue; +}; + +const buildSelectedElementLabel = ( + element: Element, + selectionContext?: SelectionContext | null, + componentNameOverride?: string | null, +): string => { + const componentName = + componentNameOverride || + selectionContext?.componentName || + selectionContext?.externalComponent?.componentName; + const sourceLocationLabel = formatSourceLocationLabel(selectionContext); + const componentLocationLabel = formatComponentLocationLabel( + componentName, + sourceLocationLabel, + ); + + const labelCandidates = [ + componentLocationLabel, + element.getAttribute('aria-label'), + getLabelledByText(element), + getAssociatedFormLabelText(element), + element.getAttribute('alt'), + element.getAttribute('title'), + element.getAttribute('placeholder'), + element.getAttribute('name'), + element.id ? `#${element.id}` : '', + ]; + + for (const labelCandidate of labelCandidates) { + const normalizedLabel = normalizeElementLabel(labelCandidate); + if (!normalizedLabel) continue; + + if (normalizedLabel === componentLocationLabel) { + return truncateElementLabel(normalizedLabel); + } + + return truncateElementLabel( + formatDomElementLabel(element, normalizedLabel), + ); + } + + if (sourceLocationLabel) { + return truncateElementLabel(sourceLocationLabel); + } + + const visibleTextLabel = getMeaningfulVisibleText(element); + if (visibleTextLabel) { + return truncateElementLabel( + formatDomElementLabel(element, visibleTextLabel), + ); + } + + return truncateElementLabel( + sourceLocationLabel || element.tagName.toLowerCase(), + ); +}; + +const getSourceRoot = (): string | undefined => + target[__AGENTIC_REACT_CONFIG__]?.sourceRoot; + +const toTextContext = (selectionContext: SelectionContext): string => + buildWebContextText(selectionContext, { + sourceRoot: getSourceRoot(), + }); + +const copyText = async (text: string): Promise => { + if (target.navigator?.clipboard?.writeText) { + try { + await target.navigator.clipboard.writeText(text); + return true; + } catch (_error) { + // fallback below + } + } + + try { + const textAreaElement = document.createElement('textarea'); + textAreaElement.value = text; + textAreaElement.style.position = 'fixed'; + textAreaElement.style.left = '-9999px'; + document.body.appendChild(textAreaElement); + textAreaElement.select(); + const didCopy = document.execCommand('copy'); + textAreaElement.remove(); + return didCopy; + } catch (_error) { + return false; + } +}; + +const createOverlayIconButton = ( + label: string, + iconPathMarkup: string, +): HTMLButtonElement => { + const buttonElement = document.createElement('button'); + const iconElement = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'svg', + ); + + buttonElement.setAttribute('type', 'button'); + buttonElement.setAttribute('aria-label', label); + buttonElement.setAttribute('title', label); + buttonElement.style.width = '24px'; + buttonElement.style.height = '24px'; + buttonElement.style.border = '1px solid rgba(255, 255, 255, 0.86)'; + buttonElement.style.borderRadius = '6px'; + buttonElement.style.background = 'rgba(15, 23, 42, 0.96)'; + buttonElement.style.color = '#ffffff'; + buttonElement.style.display = 'inline-flex'; + buttonElement.style.alignItems = 'center'; + buttonElement.style.justifyContent = 'center'; + buttonElement.style.padding = '0'; + buttonElement.style.cursor = 'pointer'; + buttonElement.style.pointerEvents = 'auto'; + + iconElement.setAttribute('viewBox', '0 0 24 24'); + iconElement.setAttribute('width', '14'); + iconElement.setAttribute('height', '14'); + iconElement.setAttribute('fill', 'none'); + iconElement.setAttribute('stroke', 'currentColor'); + iconElement.setAttribute('stroke-width', '2'); + iconElement.setAttribute('stroke-linecap', 'round'); + iconElement.setAttribute('stroke-linejoin', 'round'); + iconElement.setAttribute('aria-hidden', 'true'); + iconElement.innerHTML = iconPathMarkup; + buttonElement.appendChild(iconElement); + + return buttonElement; +}; + +const createOverlayActions = (): OverlayActionElements => { + const actionsElement = document.createElement('div'); + const tuneButtonElement = createOverlayIconButton( + 'Adjust selection', + '', + ); + const deleteButtonElement = createOverlayIconButton( + 'Delete selection', + '', + ); + + actionsElement.setAttribute('data-agentic-react-selected-actions', 'true'); + actionsElement.style.position = 'absolute'; + actionsElement.style.pointerEvents = 'auto'; + actionsElement.style.display = 'inline-flex'; + actionsElement.style.gap = '4px'; + actionsElement.style.alignItems = 'center'; + + actionsElement.appendChild(tuneButtonElement); + actionsElement.appendChild(deleteButtonElement); + + return { + actionsElement, + tuneButtonElement, + deleteButtonElement, + }; +}; + +const getNormalizedSourceRoot = (): string => + normalizeSourceRoot(getSourceRoot()); + +const extractRawSourceFromViteModule = (moduleText: string): string | null => { + const trimmedText = moduleText.trim(); + const defaultExportMatch = trimmedText.match( + /^export\s+default\s+((?:"(?:\\.|[^"])*")|(?:'(?:\\.|[^'])*'));\s*(?:\/\/# sourceMappingURL=[\s\S]*)?$/, + ); + + if (!defaultExportMatch) { + return null; + } + + const exportedValueText = defaultExportMatch[1]?.trim(); + if (!exportedValueText) { + return null; + } + + if ( + (exportedValueText.startsWith('"') && exportedValueText.endsWith('"')) || + (exportedValueText.startsWith("'") && exportedValueText.endsWith("'")) + ) { + try { + const normalizedQuotedText = exportedValueText.startsWith("'") + ? `"${exportedValueText + .slice(1, -1) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"')}"` + : exportedValueText; + return JSON.parse(normalizedQuotedText); + } catch (_error) { + return null; + } + } + + return null; +}; + +const normalizeSourceTextFromViteResponse = ( + responseText: string, +): string | null => { + const rawSource = extractRawSourceFromViteModule(responseText); + if (rawSource) { + return rawSource; + } + + if (responseText.includes('/node_modules/.vite/deps/')) { + return null; + } + + return responseText; +}; + +const placeResolvedSourceFirst = ( + resolvedSources: SelectionResolvedSource[], + preferredSource: SelectionResolvedSource, +): SelectionResolvedSource[] => [ + preferredSource, + ...resolvedSources.filter( + (source) => + source.filePath !== preferredSource.filePath || + source.componentName !== preferredSource.componentName, + ), +]; + +const enrichSelectionContextSourceLocation = async ( + selectionContext: SelectionContext, +): Promise => { + if (!selectionContext.componentName || !selectionContext.selector) { + return selectionContext; + } + + try { + const lookupUrl = new URL(SOURCE_LOOKUP_PATH, window.location.origin); + lookupUrl.searchParams.set('component', selectionContext.componentName); + lookupUrl.searchParams.set('selector', selectionContext.selector); + const response = await fetch(lookupUrl, { cache: 'no-store' }); + if (!response.ok) { + return selectionContext; + } + + const source = (await response.json()) as SelectionResolvedSource; + if (!source.filePath || !source.componentName) { + return selectionContext; + } + + const nextSelectionContext = { + ...selectionContext, + externalComponent: selectionContext.externalComponent + ? { + ...selectionContext.externalComponent, + usedBy: source, + } + : null, + resolvedSources: placeResolvedSourceFirst( + selectionContext.resolvedSources, + source, + ), + }; + + return { + ...nextSelectionContext, + sourcePreview: buildSelectionSourcePreview(nextSelectionContext), + }; + } catch (_error) { + return selectionContext; + } +}; + +const buildSourceSnippet = ( + sourceText: string, + lineNumber: number, + contextLines: number, +): { startLine: number; endLine: number; snippet: string } | null => { + const lines = sourceText.split(/\r?\n/); + if (lines.length === 0) { + return null; + } + + const safeLineNumber = Math.max(1, Math.min(lineNumber, lines.length)); + const startLine = Math.max(1, safeLineNumber - contextLines); + const endLine = Math.min(lines.length, safeLineNumber + contextLines); + const snippet = lines + .slice(startLine - 1, endLine) + .join('\n') + .trim(); + + if (!snippet) { + return null; + } + + return { + startLine, + endLine, + snippet, + }; +}; + +export const createSelectionToolkit = ( + options: ToolkitRuntimeOptions = {}, +): ToolkitRuntimeResult => { + let toolkitConfig = mergeToolkitConfig(options.initialConfig, undefined); + let isToolkitVisible = toolkitConfig.defaultVisible && toolkitConfig.enabled; + let isPanelOpen = toolkitConfig.defaultExpanded; + let isSelectionMode = false; + let isMultiSelectionMode = false; + let lastSelectionContext: SelectionContext | null = null; + let multiSelectionContexts: SelectionContext[] = []; + + const toolkitRootElement = document.createElement('div'); + const launcherButtonElement = document.createElement('button'); + const launcherIconElement = document.createElement('img'); + + const panelElement = document.createElement('div'); + const selectButtonElement = document.createElement('button'); + const multiselectButtonElement = document.createElement('button'); + const doneButtonElement = document.createElement('button'); + const clearAllButtonElement = document.createElement('button'); + const clearAllIconElement = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'svg', + ); + const clearAllLabelElement = document.createElement('span'); + const statusElement = document.createElement('div'); + const hoverElement = document.createElement('div'); + const hoverLabelElement = document.createElement('div'); + const selectedElement = document.createElement('div'); + const selectedLabelElement = document.createElement('div'); + const selectedActions = createOverlayActions(); + const tuningModalElement = document.createElement('div'); + const tuningSurfaceElement = document.createElement('div'); + const tuningPanelElement = document.createElement('div'); + const tuningArrowElement = document.createElement('div'); + const tuningTitleElement = document.createElement('div'); + const tuningBodyElement = document.createElement('div'); + const tuningCloseButtonElement = document.createElement('button'); + const dimElements = Array.from({ length: 4 }, () => + document.createElement('div'), + ); + let selectedTargetElement: Element | null = null; + let dimTargetElement: Element | null = null; + let hoverLabelTargetElement: Element | null = null; + let hoverLabelRequestId = 0; + let dimHideTimeout: number | null = null; + let selectedPulseTimeout: number | null = null; + let activeTuningSession: TuningSession | null = null; + let tuningModalExtensions: TuningModalExtension[] = []; + const tuningExtensionCleanups: Array<() => void> = []; + let multiSelectedOverlays: Array<{ + element: Element; + overlayElement: HTMLDivElement; + labelElement: HTMLDivElement; + actions: OverlayActionElements; + selectionContext: SelectionContext; + pulseTimeout: number | null; + }> = []; + + toolkitRootElement.setAttribute('data-agentic-react-toolkit', 'true'); + toolkitRootElement.style.position = 'fixed'; + toolkitRootElement.style.display = 'flex'; + toolkitRootElement.style.flexDirection = 'column'; + toolkitRootElement.style.alignItems = 'flex-end'; + toolkitRootElement.style.gap = '10px'; + toolkitRootElement.style.fontFamily = 'ui-sans-serif, system-ui, sans-serif'; + toolkitRootElement.style.pointerEvents = 'auto'; + + launcherButtonElement.setAttribute('type', 'button'); + launcherButtonElement.setAttribute( + 'aria-label', + 'Open Agentic React toolkit', + ); + launcherButtonElement.setAttribute('data-agentic-react-launcher', 'true'); + launcherButtonElement.style.width = `${LAUNCHER_SIZE}px`; + launcherButtonElement.style.height = `${LAUNCHER_SIZE}px`; + launcherButtonElement.style.borderRadius = '999px'; + launcherButtonElement.style.border = 'none'; + launcherButtonElement.style.background = 'transparent'; + launcherButtonElement.style.boxShadow = + 'var(--agentic-react-launcher-shadow)'; + launcherButtonElement.style.cursor = 'pointer'; + launcherButtonElement.style.display = 'flex'; + launcherButtonElement.style.alignItems = 'center'; + launcherButtonElement.style.justifyContent = 'center'; + launcherButtonElement.style.padding = '0'; + launcherButtonElement.style.overflow = 'hidden'; + launcherButtonElement.style.transition = + 'transform 140ms ease, box-shadow 140ms ease, filter 140ms ease, background-color 140ms ease'; + launcherButtonElement.style.willChange = 'transform, box-shadow, filter'; + + launcherIconElement.style.width = '100%'; + launcherIconElement.style.height = '100%'; + launcherIconElement.style.objectFit = 'cover'; + launcherIconElement.style.display = 'block'; + + launcherButtonElement.appendChild(launcherIconElement); + + panelElement.style.display = 'none'; + panelElement.style.flexDirection = 'column'; + panelElement.style.gap = '8px'; + panelElement.style.background = 'rgba(255,255,255,0.96)'; + panelElement.style.border = '1px solid rgba(0,0,0,0.12)'; + panelElement.style.borderRadius = '10px'; + panelElement.style.padding = '10px'; + panelElement.style.minWidth = '206px'; + panelElement.style.maxWidth = '280px'; + panelElement.style.boxShadow = '0 16px 35px rgba(15, 23, 42, 0.26)'; + + const createButton = (buttonElement: HTMLButtonElement, label: string) => { + buttonElement.textContent = label; + buttonElement.style.padding = '8px 12px'; + buttonElement.style.borderRadius = '8px'; + buttonElement.style.border = '1px solid rgba(255,255,255,0.2)'; + buttonElement.style.background = toolkitConfig.accentColor; + buttonElement.style.color = '#ffffff'; + buttonElement.style.fontSize = '12px'; + buttonElement.style.fontWeight = '600'; + buttonElement.style.cursor = 'pointer'; + buttonElement.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)'; + buttonElement.style.minWidth = '96px'; + }; + + const createTrashIconButton = () => { + clearAllButtonElement.setAttribute('type', 'button'); + clearAllButtonElement.setAttribute('aria-label', 'Clear all selections'); + clearAllButtonElement.setAttribute('title', 'Clear all selections'); + clearAllButtonElement.setAttribute('data-agentic-react-clear-all', 'true'); + clearAllButtonElement.style.padding = '8px 12px'; + clearAllButtonElement.style.borderRadius = '8px'; + clearAllButtonElement.style.border = '1px solid rgba(255,255,255,0.2)'; + clearAllButtonElement.style.background = '#334155'; + clearAllButtonElement.style.color = '#ffffff'; + clearAllButtonElement.style.cursor = 'pointer'; + clearAllButtonElement.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)'; + clearAllButtonElement.style.minWidth = '96px'; + clearAllButtonElement.style.height = '34px'; + clearAllButtonElement.style.display = 'none'; + clearAllButtonElement.style.alignItems = 'center'; + clearAllButtonElement.style.justifyContent = 'center'; + clearAllButtonElement.style.gap = '6px'; + clearAllButtonElement.style.fontSize = '12px'; + clearAllButtonElement.style.fontWeight = '600'; + + clearAllIconElement.setAttribute('viewBox', '0 0 24 24'); + clearAllIconElement.setAttribute('width', '16'); + clearAllIconElement.setAttribute('height', '16'); + clearAllIconElement.setAttribute('fill', 'none'); + clearAllIconElement.setAttribute('stroke', 'currentColor'); + clearAllIconElement.setAttribute('stroke-width', '2'); + clearAllIconElement.setAttribute('stroke-linecap', 'round'); + clearAllIconElement.setAttribute('stroke-linejoin', 'round'); + clearAllIconElement.setAttribute('aria-hidden', 'true'); + clearAllIconElement.innerHTML = + ''; + + clearAllLabelElement.textContent = 'Clear all'; + clearAllLabelElement.style.lineHeight = '1'; + clearAllLabelElement.style.whiteSpace = 'nowrap'; + + clearAllButtonElement.appendChild(clearAllIconElement); + clearAllButtonElement.appendChild(clearAllLabelElement); + }; + + createButton(selectButtonElement, 'Select'); + createButton(multiselectButtonElement, 'Multiselect'); + createButton(doneButtonElement, 'Done'); + createTrashIconButton(); + + doneButtonElement.style.background = '#dc2626'; + doneButtonElement.style.color = '#ffffff'; + doneButtonElement.style.display = 'none'; + + statusElement.style.fontSize = '11px'; + statusElement.style.color = '#111827'; + statusElement.style.background = 'rgba(255, 255, 255, 0.95)'; + statusElement.style.border = '1px solid rgba(0,0,0,0.1)'; + statusElement.style.borderRadius = '8px'; + statusElement.style.padding = '6px 8px'; + statusElement.style.maxWidth = '280px'; + statusElement.style.display = 'none'; + + panelElement.appendChild(selectButtonElement); + panelElement.appendChild(multiselectButtonElement); + panelElement.appendChild(doneButtonElement); + panelElement.appendChild(clearAllButtonElement); + panelElement.appendChild(statusElement); + + toolkitRootElement.appendChild(panelElement); + toolkitRootElement.appendChild(launcherButtonElement); + + hoverElement.setAttribute('data-agentic-react-hover', 'true'); + hoverElement.style.position = 'fixed'; + hoverElement.style.pointerEvents = 'none'; + hoverElement.style.display = 'none'; + hoverElement.style.setProperty('--agentic-react-hover-inner', CONTRAST_DARK); + hoverElement.style.setProperty('--agentic-react-hover-outer', CONTRAST_LIGHT); + hoverElement.style.setProperty( + '--agentic-react-hover-fill', + 'rgba(15, 23, 42, 0.08)', + ); + hoverElement.appendChild(hoverLabelElement); + + hoverLabelElement.setAttribute('data-agentic-react-hover-label', 'true'); + hoverLabelElement.style.position = 'absolute'; + hoverLabelElement.style.pointerEvents = 'none'; + hoverLabelElement.style.maxWidth = 'min(420px, calc(100vw - 24px))'; + hoverLabelElement.style.overflow = 'hidden'; + hoverLabelElement.style.textOverflow = 'ellipsis'; + hoverLabelElement.style.whiteSpace = 'nowrap'; + + selectedElement.setAttribute('data-agentic-react-selected', 'true'); + selectedElement.style.position = 'fixed'; + selectedElement.style.pointerEvents = 'none'; + selectedElement.style.display = 'none'; + selectedElement.style.background = 'transparent'; + selectedElement.appendChild(selectedLabelElement); + selectedElement.appendChild(selectedActions.actionsElement); + + selectedLabelElement.setAttribute( + 'data-agentic-react-selected-label', + 'true', + ); + selectedLabelElement.style.position = 'absolute'; + selectedLabelElement.style.pointerEvents = 'none'; + selectedLabelElement.style.maxWidth = 'min(260px, calc(100vw - 24px))'; + selectedLabelElement.style.overflow = 'hidden'; + selectedLabelElement.style.textOverflow = 'ellipsis'; + selectedLabelElement.style.whiteSpace = 'nowrap'; + + tuningModalElement.setAttribute('data-agentic-react-tuning-modal', 'true'); + tuningModalElement.style.position = 'fixed'; + tuningModalElement.style.inset = '0'; + tuningModalElement.style.display = 'none'; + tuningModalElement.style.background = 'transparent'; + tuningModalElement.style.pointerEvents = 'auto'; + + tuningSurfaceElement.setAttribute( + 'data-agentic-react-tuning-surface', + 'true', + ); + tuningSurfaceElement.style.position = 'fixed'; + tuningSurfaceElement.style.pointerEvents = 'auto'; + tuningSurfaceElement.style.width = 'fit-content'; + tuningSurfaceElement.style.maxWidth = 'calc(100vw - 32px)'; + + tuningPanelElement.setAttribute('data-agentic-react-tuning-panel', 'true'); + tuningPanelElement.style.position = 'relative'; + tuningPanelElement.style.width = 'min(420px, calc(100vw - 32px))'; + tuningPanelElement.style.maxHeight = 'min(50vh, calc(100vh - 24px))'; + tuningPanelElement.style.overflowY = 'auto'; + tuningPanelElement.style.overscrollBehavior = 'contain'; + tuningPanelElement.style.background = + 'var(--agentic-react-tuning-panel-background, #ffffff)'; + tuningPanelElement.style.border = + 'var(--agentic-react-tuning-panel-border, 1px solid rgba(15, 23, 42, 0.14))'; + tuningPanelElement.style.borderRadius = + 'var(--agentic-react-tuning-panel-radius, 10px)'; + tuningPanelElement.style.boxShadow = + 'var(--agentic-react-tuning-panel-shadow, 0 22px 60px rgba(15, 23, 42, 0.3))'; + tuningPanelElement.style.padding = '12px'; + tuningPanelElement.style.color = + 'var(--agentic-react-tuning-panel-color, #111827)'; + tuningPanelElement.style.fontFamily = 'ui-sans-serif, system-ui, sans-serif'; + tuningPanelElement.style.pointerEvents = 'auto'; + + tuningArrowElement.setAttribute('aria-hidden', 'true'); + tuningArrowElement.style.position = 'fixed'; + tuningArrowElement.style.width = '10px'; + tuningArrowElement.style.height = '10px'; + tuningArrowElement.style.background = + 'var(--agentic-react-tuning-panel-background, #ffffff)'; + tuningArrowElement.style.transform = 'rotate(45deg)'; + tuningArrowElement.style.pointerEvents = 'none'; + + tuningTitleElement.style.fontSize = '13px'; + tuningTitleElement.style.fontWeight = '700'; + tuningTitleElement.style.marginBottom = '8px'; + + tuningBodyElement.style.display = 'flex'; + tuningBodyElement.style.flexDirection = 'column'; + tuningBodyElement.style.gap = '8px'; + + tuningCloseButtonElement.setAttribute('type', 'button'); + tuningCloseButtonElement.setAttribute('aria-label', 'Close tuning options'); + tuningCloseButtonElement.textContent = 'Close'; + tuningCloseButtonElement.style.marginTop = '12px'; + tuningCloseButtonElement.style.width = '100%'; + tuningCloseButtonElement.style.padding = '8px 10px'; + tuningCloseButtonElement.style.border = + 'var(--agentic-react-tuning-control-border, 1px solid rgba(15, 23, 42, 0.14))'; + tuningCloseButtonElement.style.borderRadius = + 'var(--agentic-react-tuning-control-radius, 8px)'; + tuningCloseButtonElement.style.background = + 'var(--agentic-react-tuning-secondary-button-background, #f8fafc)'; + tuningCloseButtonElement.style.color = + 'var(--agentic-react-tuning-secondary-button-color, #111827)'; + tuningCloseButtonElement.style.cursor = 'pointer'; + + tuningPanelElement.appendChild(tuningTitleElement); + tuningPanelElement.appendChild(tuningBodyElement); + tuningPanelElement.appendChild(tuningCloseButtonElement); + tuningModalElement.appendChild(tuningArrowElement); + tuningSurfaceElement.appendChild(tuningPanelElement); + tuningModalElement.appendChild(tuningSurfaceElement); + + const getTuningModalConfig = () => toolkitConfig.tuningModal || {}; + + const applyTuningTokens = (element: HTMLElement) => { + const tokens = getTuningModalConfig().tokens || {}; + for (const [tokenName, tokenValue] of Object.entries(tokens)) { + element.style.setProperty( + getTuningTokenName(tokenName), + String(tokenValue), + ); + } + }; + + const applyTuningSlotStyles = ( + element: HTMLElement, + ...slots: ToolkitTuningModalStyleSlot[] + ) => { + const tuningModalConfig = getTuningModalConfig(); + for (const slot of slots) { + const className = tuningModalConfig.classNames?.[slot]; + if (className) { + element.classList.add( + ...className.split(/\s+/).filter((name) => name.length > 0), + ); + } + applyStyleDeclaration(element, tuningModalConfig.styles?.[slot]); + } + }; + + const applyTuningModalStaticStyles = () => { + applyTuningTokens(tuningModalElement); + applyTuningSlotStyles(tuningModalElement, 'root'); + applyTuningSlotStyles(tuningSurfaceElement, 'surface'); + applyTuningSlotStyles(tuningPanelElement, 'panel'); + applyTuningSlotStyles(tuningArrowElement, 'arrow'); + applyTuningSlotStyles(tuningTitleElement, 'title'); + applyTuningSlotStyles(tuningBodyElement, 'body'); + applyTuningSlotStyles(tuningCloseButtonElement, 'closeButton'); + }; + + applyTuningModalStaticStyles(); + + for (const dimElement of dimElements) { + dimElement.setAttribute('data-agentic-react-dim', 'true'); + dimElement.style.position = 'fixed'; + dimElement.style.pointerEvents = 'none'; + dimElement.style.display = 'none'; + dimElement.style.background = 'rgba(15, 23, 42, 0.28)'; + dimElement.style.backdropFilter = 'saturate(0.8)'; + } + + const mountSelectionStyles = () => { + if (document.getElementById(SELECTION_STYLE_ID)) { + return; + } + + const styleElement = document.createElement('style'); + styleElement.id = SELECTION_STYLE_ID; + styleElement.textContent = ` + [data-agentic-react-selected="true"] { + box-sizing: border-box; + border: 2px solid rgba(255, 255, 255, 0.98); + box-shadow: + 0 0 0 2px rgba(15, 23, 42, 0.96), + 0 0 0 6px rgba(255, 255, 255, 0.96), + 0 0 0 9px var(--agentic-react-selection-accent, #111827), + 0 16px 38px rgba(15, 23, 42, 0.22); + transform-origin: center; + animation: agentic-react-selected-idle-pulse 2200ms cubic-bezier(0.37, 0, 0.63, 1) infinite; + backface-visibility: hidden; + will-change: transform, box-shadow; + } + + [data-agentic-react-selected-label="true"], + [data-agentic-react-hover-label="true"] { + box-sizing: border-box; + left: 0; + bottom: calc(100% + 6px); + padding: 4px 6px; + border: 1px solid rgba(255, 255, 255, 0.86); + border-radius: 6px; + background: rgba(15, 23, 42, 0.96); + color: #ffffff; + box-shadow: + 0 0 0 1px rgba(15, 23, 42, 0.72), + 0 10px 24px rgba(15, 23, 42, 0.28); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 10px; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0; + } + + [data-agentic-react-hover-label="true"] { + background: rgba(15, 23, 42, 0.9); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.44), + 0 8px 18px rgba(15, 23, 42, 0.24); + } + + [data-agentic-react-selected-label="true"][data-align="left"], + [data-agentic-react-hover-label="true"][data-align="left"] { + left: 0; + } + + [data-agentic-react-selected-actions="true"] { + right: 0; + bottom: calc(100% + 6px); + } + + [data-agentic-react-hover="true"] { + box-sizing: border-box; + border: 2px solid var(--agentic-react-hover-inner, rgba(15, 23, 42, 0.98)); + background: var(--agentic-react-hover-fill, rgba(15, 23, 42, 0.08)); + box-shadow: + 0 0 0 2px var(--agentic-react-hover-outer, rgba(255, 255, 255, 0.98)), + 0 0 0 5px var(--agentic-react-selection-accent, #111827), + inset 0 0 0 1px rgba(255, 255, 255, 0.18), + 0 12px 30px rgba(15, 23, 42, 0.2); + transform: translateZ(0); + transform-origin: center; + animation: agentic-react-hover-pulse 2000ms cubic-bezier(0.37, 0, 0.63, 1) infinite; + backface-visibility: hidden; + will-change: transform, box-shadow; + } + + [data-agentic-react-launcher="true"] { + box-sizing: border-box; + transform: translateZ(0); + -webkit-tap-highlight-color: transparent; + } + + [data-agentic-react-launcher="true"]:hover { + background: rgba(255, 255, 255, 0.16) !important; + box-shadow: var(--agentic-react-launcher-hover-shadow) !important; + filter: brightness(1.08) saturate(1.08); + transform: translateY(-2px) scale(1.035) !important; + } + + [data-agentic-react-launcher="true"]:focus-visible { + box-shadow: var(--agentic-react-launcher-focus-shadow) !important; + outline: none; + transform: translateY(-1px) scale(1.02) !important; + } + + [data-agentic-react-launcher="true"]:active { + filter: brightness(0.98) saturate(1.02); + transform: translateY(0) scale(0.99) !important; + } + + [data-agentic-react-selected="true"].agentic-react-selection-pulse { + animation: agentic-react-selection-pulse ${SELECTION_CONFIRMATION_MS}ms cubic-bezier(0.22, 1, 0.36, 1); + } + + @keyframes agentic-react-selected-idle-pulse { + 0%, + 100% { + transform: scale(1); + box-shadow: + 0 0 0 2px rgba(15, 23, 42, 0.96), + 0 0 0 6px rgba(255, 255, 255, 0.96), + 0 0 0 9px var(--agentic-react-selection-accent, #111827), + 0 16px 38px rgba(15, 23, 42, 0.22); + } + 42% { + transform: scale(1.009); + box-shadow: + 0 0 0 3px rgba(15, 23, 42, 0.98), + 0 0 0 7px rgba(255, 255, 255, 0.97), + 0 0 0 11px var(--agentic-react-selection-accent, #111827), + 0 17px 40px rgba(15, 23, 42, 0.24); + } + 70% { + transform: scale(0.999); + box-shadow: + 0 0 0 2px rgba(15, 23, 42, 0.96), + 0 0 0 6px rgba(255, 255, 255, 0.94), + 0 0 0 8px var(--agentic-react-selection-accent, #111827), + 0 15px 36px rgba(15, 23, 42, 0.21); + } + } + + @keyframes agentic-react-hover-pulse { + 0%, + 100% { + transform: translateZ(0) scale(1); + box-shadow: + 0 0 0 2px var(--agentic-react-hover-outer, rgba(255, 255, 255, 0.98)), + 0 0 0 5px var(--agentic-react-selection-accent, #111827), + inset 0 0 0 1px rgba(255, 255, 255, 0.18), + 0 12px 30px rgba(15, 23, 42, 0.2); + } + 44% { + transform: translateZ(0) scale(1.01); + box-shadow: + 0 0 0 3px var(--agentic-react-hover-outer, rgba(255, 255, 255, 0.98)), + 0 0 0 7px var(--agentic-react-selection-accent, #111827), + inset 0 0 0 1px rgba(255, 255, 255, 0.22), + 0 14px 34px rgba(15, 23, 42, 0.22); + } + 72% { + transform: translateZ(0) scale(0.999); + box-shadow: + 0 0 0 2px var(--agentic-react-hover-outer, rgba(255, 255, 255, 0.98)), + 0 0 0 5px var(--agentic-react-selection-accent, #111827), + inset 0 0 0 1px rgba(255, 255, 255, 0.16), + 0 11px 28px rgba(15, 23, 42, 0.19); + } + } + + @keyframes agentic-react-selection-pulse { + 0% { + transform: scale(1); + box-shadow: + 0 0 0 2px rgba(15, 23, 42, 0.96), + 0 0 0 6px rgba(255, 255, 255, 0.96), + 0 0 0 9px var(--agentic-react-selection-accent, #111827), + 0 16px 38px rgba(15, 23, 42, 0.22); + } + 38% { + transform: scale(1.018); + box-shadow: + 0 0 0 3px rgba(15, 23, 42, 0.98), + 0 0 0 8px rgba(255, 255, 255, 0.98), + 0 0 0 13px var(--agentic-react-selection-accent, #111827), + 0 19px 44px rgba(15, 23, 42, 0.26); + } + 72% { + transform: scale(0.998); + box-shadow: + 0 0 0 2px rgba(15, 23, 42, 0.96), + 0 0 0 6px rgba(255, 255, 255, 0.94), + 0 0 0 8px var(--agentic-react-selection-accent, #111827), + 0 15px 36px rgba(15, 23, 42, 0.21); + } + 100% { + transform: scale(1); + box-shadow: + 0 0 0 2px rgba(15, 23, 42, 0.96), + 0 0 0 6px rgba(255, 255, 255, 0.96), + 0 0 0 9px var(--agentic-react-selection-accent, #111827), + 0 16px 38px rgba(15, 23, 42, 0.22); + } + } + `; + (document.head || document.documentElement).appendChild(styleElement); + }; + + const updateStatus = (message: string, shouldDisplay = true) => { + statusElement.textContent = message; + statusElement.style.display = shouldDisplay ? 'block' : 'none'; + }; + + const renderMultiSelectionControls = () => { + const hasSelections = multiSelectionContexts.length > 0; + doneButtonElement.style.display = isMultiSelectionMode ? 'block' : 'none'; + clearAllButtonElement.style.display = isMultiSelectionMode + ? 'flex' + : 'none'; + clearAllButtonElement.disabled = !hasSelections; + clearAllButtonElement.style.opacity = hasSelections ? '1' : '0.45'; + clearAllButtonElement.style.cursor = hasSelections + ? 'pointer' + : 'not-allowed'; + clearAllButtonElement.style.background = hasSelections + ? '#334155' + : '#64748b'; + }; + + const renderPanelVisibility = () => { + renderMultiSelectionControls(); + panelElement.style.display = isPanelOpen ? 'flex' : 'none'; + }; + + const renderLauncherLogo = () => { + launcherIconElement.src = + (toolkitConfig.iconUrl || '').trim() || DEFAULT_TOOLKIT_ICON_DATA_URL; + }; + + const updateToolkitStyle = () => { + toolkitRootElement.style.zIndex = String(toolkitConfig.zIndex); + setPosition( + toolkitRootElement, + toolkitConfig.position, + toolkitConfig.offset.x || 0, + toolkitConfig.offset.y || 0, + ); + + panelElement.style.borderColor = `${toolkitConfig.accentColor}33`; + launcherButtonElement.style.setProperty( + '--agentic-react-launcher-shadow', + `0 18px 38px rgba(15, 23, 42, 0.34), 0 8px 18px ${toolkitConfig.accentColor}3d, 0 0 0 1px rgba(255, 255, 255, 0.2)`, + ); + launcherButtonElement.style.setProperty( + '--agentic-react-launcher-hover-shadow', + `0 24px 48px rgba(15, 23, 42, 0.42), 0 12px 28px ${toolkitConfig.accentColor}66, 0 0 0 4px ${toolkitConfig.accentColor}24, 0 0 0 1px rgba(255, 255, 255, 0.42)`, + ); + launcherButtonElement.style.setProperty( + '--agentic-react-launcher-focus-shadow', + `0 20px 42px rgba(15, 23, 42, 0.38), 0 10px 24px ${toolkitConfig.accentColor}5c, 0 0 0 4px ${toolkitConfig.accentColor}33, 0 0 0 1px rgba(255, 255, 255, 0.5)`, + ); + + const buttons = [selectButtonElement, multiselectButtonElement]; + for (const buttonElement of buttons) { + if (!buttonElement.disabled) { + buttonElement.style.background = toolkitConfig.accentColor; + } + buttonElement.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)'; + } + doneButtonElement.style.background = '#dc2626'; + doneButtonElement.style.color = '#ffffff'; + doneButtonElement.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)'; + clearAllButtonElement.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)'; + + hoverElement.style.zIndex = String(Math.max(0, toolkitConfig.zIndex - 2)); + hoverElement.style.setProperty( + '--agentic-react-selection-accent', + toolkitConfig.accentColor, + ); + selectedElement.style.zIndex = String( + Math.max(0, toolkitConfig.zIndex - 1), + ); + selectedElement.style.setProperty( + '--agentic-react-selection-accent', + toolkitConfig.accentColor, + ); + for (const { overlayElement } of multiSelectedOverlays) { + overlayElement.style.zIndex = String( + Math.max(0, toolkitConfig.zIndex - 1), + ); + overlayElement.style.setProperty( + '--agentic-react-selection-accent', + toolkitConfig.accentColor, + ); + } + for (const dimElement of dimElements) { + dimElement.style.zIndex = String(Math.max(0, toolkitConfig.zIndex - 3)); + } + applyTuningModalStaticStyles(); + renderLauncherLogo(); + renderPanelVisibility(); + }; + + const applyElementRect = (overlayElement: HTMLElement, rect: DOMRect) => { + overlayElement.style.left = `${rect.left}px`; + overlayElement.style.top = `${rect.top}px`; + overlayElement.style.width = `${rect.width}px`; + overlayElement.style.height = `${rect.height}px`; + }; + + const updateOverlayLabelForElement = ( + labelElement: HTMLElement, + element: Element, + rect: DOMRect, + selectionContext?: SelectionContext | null, + ) => { + const componentNameOverride = selectionContext + ? null + : getSelectionElementComponentName(element); + labelElement.textContent = buildSelectedElementLabel( + element, + selectionContext, + componentNameOverride, + ); + labelElement.dataset.align = 'left'; + labelElement.style.maxWidth = `${Math.max( + 80, + Math.min(420, window.innerWidth - 24, window.innerWidth - rect.left - 12), + )}px`; + }; + + const closeTuningModal = () => { + for (const cleanup of tuningExtensionCleanups.splice(0)) { + try { + cleanup(); + } catch (_error) { + // Extension cleanup should not prevent the built-in modal from closing. + } + } + activeTuningSession = null; + tuningModalElement.style.display = 'none'; + tuningBodyElement.replaceChildren(); + tuningSurfaceElement.replaceChildren(tuningPanelElement); + }; + + const positionTuningPanel = () => { + if (!activeTuningSession?.anchorElement.isConnected) { + closeTuningModal(); + return; + } + + const anchorRect = + activeTuningSession.anchorElement.getBoundingClientRect(); + const surfaceRect = tuningSurfaceElement.getBoundingClientRect(); + const panelRect = tuningPanelElement.getBoundingClientRect(); + const surfaceWidth = surfaceRect.width || panelRect.width; + const surfaceHeight = surfaceRect.height || panelRect.height; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const maxLeft = Math.max( + TUNING_PANEL_MARGIN, + viewportWidth - TUNING_PANEL_MARGIN - surfaceWidth, + ); + const maxTop = Math.max( + TUNING_PANEL_MARGIN, + viewportHeight - TUNING_PANEL_MARGIN - surfaceHeight, + ); + const rightLeft = anchorRect.right + TUNING_PANEL_GAP; + const leftLeft = anchorRect.left - TUNING_PANEL_GAP - surfaceWidth; + const canFitRight = + rightLeft + surfaceWidth <= viewportWidth - TUNING_PANEL_MARGIN; + const canFitLeft = leftLeft >= TUNING_PANEL_MARGIN; + const placeOnRight = + canFitRight || + (!canFitLeft && viewportWidth - anchorRect.right >= anchorRect.left); + const unclampedLeft = placeOnRight ? rightLeft : leftLeft; + const left = clampNumber(unclampedLeft, TUNING_PANEL_MARGIN, maxLeft); + const anchorCenterY = anchorRect.top + anchorRect.height / 2; + const top = clampNumber( + anchorCenterY - surfaceHeight / 2, + TUNING_PANEL_MARGIN, + maxTop, + ); + const arrowTop = clampNumber( + anchorCenterY - top - 5, + 14, + Math.max(14, surfaceHeight - 18), + ); + + tuningSurfaceElement.dataset.placement = placeOnRight ? 'right' : 'left'; + tuningSurfaceElement.style.left = `${Math.round(left)}px`; + tuningSurfaceElement.style.top = `${Math.round(top)}px`; + tuningSurfaceElement.style.right = ''; + tuningSurfaceElement.style.bottom = ''; + tuningArrowElement.style.top = `${Math.round(top + arrowTop)}px`; + tuningArrowElement.style.left = placeOnRight + ? `${Math.round(left - 6)}px` + : `${Math.round(left + surfaceWidth - 4)}px`; + tuningArrowElement.style.right = ''; + tuningArrowElement.style.borderLeft = placeOnRight + ? '1px solid rgba(15, 23, 42, 0.14)' + : 'none'; + tuningArrowElement.style.borderBottom = placeOnRight + ? '1px solid rgba(15, 23, 42, 0.14)' + : 'none'; + tuningArrowElement.style.borderRight = placeOnRight + ? 'none' + : '1px solid rgba(15, 23, 42, 0.14)'; + tuningArrowElement.style.borderTop = placeOnRight + ? 'none' + : '1px solid rgba(15, 23, 42, 0.14)'; + }; + + const addTuningPrompt = (prompt: string) => { + if (!activeTuningSession) return; + + const nextTuningPrompts = [ + ...(activeTuningSession.selectionContext.tuningPrompts || []), + prompt, + ]; + const nextSelectionContext = { + ...activeTuningSession.selectionContext, + tuningPrompts: nextTuningPrompts, + }; + + activeTuningSession.selectionContext = nextSelectionContext; + activeTuningSession.setSelectionContext(nextSelectionContext); + updateStatus(`Added tuning prompt: ${prompt}`); + }; + + const buildTuningModalContext = ( + session: TuningSession, + computedStyle: CSSStyleDeclaration, + targetLabel: string, + targetTagName: string, + ): TuningModalContext => ({ + element: session.element, + tagName: targetTagName, + targetLabel, + computedStyle, + selectionContext: session.selectionContext, + }); + + const buildTuningModalActions = (): TuningModalActions => ({ + addPrompt: addTuningPrompt, + close: closeTuningModal, + requestReposition: positionTuningPanel, + }); + + const pushTuningExtensionCleanup = (cleanup: TuningModalExtensionCleanup) => { + if (typeof cleanup === 'function') { + tuningExtensionCleanups.push(cleanup); + } + }; + + const renderTuningExtensionSlot = ( + slot: 'beforeFields' | 'afterFields' | 'footer', + container: HTMLElement, + context: TuningModalContext, + actions: TuningModalActions, + ) => { + for (const extension of tuningModalExtensions) { + const renderSlot = extension[slot]; + if (!renderSlot) continue; + + pushTuningExtensionCleanup( + renderSlot({ + container, + context, + actions, + }), + ); + } + }; + + const renderTuningExtensionWrappers = ( + context: TuningModalContext, + actions: TuningModalActions, + ) => { + for (const extension of tuningModalExtensions) { + pushTuningExtensionCleanup( + extension.wrapModal?.({ + surfaceElement: tuningSurfaceElement, + panelElement: tuningPanelElement, + context, + actions, + }), + ); + } + }; + + const registerTuningModalExtension = (extension: TuningModalExtension) => { + tuningModalExtensions = [ + ...tuningModalExtensions.filter( + (registeredExtension) => registeredExtension.id !== extension.id, + ), + extension, + ]; + + return () => { + tuningModalExtensions = tuningModalExtensions.filter( + (registeredExtension) => registeredExtension.id !== extension.id, + ); + }; + }; + + const createTuningSectionTitle = (titleText: string) => { + const sectionTitleElement = document.createElement('div'); + sectionTitleElement.textContent = titleText; + sectionTitleElement.style.marginTop = '4px'; + sectionTitleElement.style.paddingTop = '8px'; + sectionTitleElement.style.borderTop = '1px solid rgba(15, 23, 42, 0.08)'; + sectionTitleElement.style.fontSize = '11px'; + sectionTitleElement.style.fontWeight = '800'; + sectionTitleElement.style.letterSpacing = '0'; + sectionTitleElement.style.color = + 'var(--agentic-react-tuning-section-color, #334155)'; + applyTuningSlotStyles(sectionTitleElement, 'sectionTitle'); + return sectionTitleElement; + }; + + const styleTuningControl = ( + controlElement: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, + ) => { + controlElement.style.width = '100%'; + controlElement.style.minWidth = '0'; + controlElement.style.boxSizing = 'border-box'; + controlElement.style.border = + 'var(--agentic-react-tuning-control-border, 1px solid rgba(15, 23, 42, 0.14))'; + controlElement.style.borderRadius = + 'var(--agentic-react-tuning-control-radius, 8px)'; + controlElement.style.background = + 'var(--agentic-react-tuning-control-background, #ffffff)'; + controlElement.style.color = + 'var(--agentic-react-tuning-control-color, #111827)'; + controlElement.style.fontSize = '12px'; + controlElement.style.fontFamily = 'ui-sans-serif, system-ui, sans-serif'; + applyTuningSlotStyles(controlElement, 'control'); + }; + + const createTuningRow = ( + labelText: string, + controlElement: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, + suffixText = '', + ) => { + const rowElement = document.createElement('label'); + const labelElement = document.createElement('span'); + const controlWrapElement = document.createElement('span'); + const suffixElement = document.createElement('span'); + + rowElement.style.display = 'grid'; + rowElement.style.gridTemplateColumns = '128px minmax(0, 1fr)'; + rowElement.style.alignItems = 'center'; + rowElement.style.gap = '12px'; + rowElement.style.fontSize = '12px'; + rowElement.style.fontWeight = '600'; + + labelElement.textContent = labelText; + labelElement.style.color = + 'var(--agentic-react-tuning-label-color, #111827)'; + labelElement.style.lineHeight = '1.2'; + controlElement.setAttribute('aria-label', labelText); + styleTuningControl(controlElement); + + const createStepperButton = (label: string, direction: -1 | 1) => { + const buttonElement = document.createElement('button'); + buttonElement.type = 'button'; + buttonElement.textContent = direction > 0 ? '+' : '-'; + buttonElement.setAttribute('aria-label', `${label} ${labelText}`); + buttonElement.style.width = '26px'; + buttonElement.style.height = '32px'; + buttonElement.style.border = + 'var(--agentic-react-tuning-control-border, 1px solid rgba(15, 23, 42, 0.14))'; + buttonElement.style.borderRadius = + 'var(--agentic-react-tuning-control-radius, 8px)'; + buttonElement.style.background = + 'var(--agentic-react-tuning-secondary-button-background, #f8fafc)'; + buttonElement.style.color = + 'var(--agentic-react-tuning-secondary-button-color, #111827)'; + buttonElement.style.cursor = 'pointer'; + buttonElement.style.fontSize = '14px'; + buttonElement.style.fontWeight = '800'; + buttonElement.style.lineHeight = '1'; + buttonElement.addEventListener('click', (event) => { + event.preventDefault(); + if (!(controlElement instanceof HTMLInputElement)) return; + + try { + if (direction > 0) { + controlElement.stepUp(); + } else { + controlElement.stepDown(); + } + } catch (_error) { + const step = Number(controlElement.step) || 1; + const nextValue = + (Number(controlElement.value) || 0) + step * direction; + controlElement.value = String(nextValue); + } + + controlElement.dispatchEvent( + new Event('change', { bubbles: true, cancelable: true }), + ); + }); + applyTuningSlotStyles(buttonElement, 'stepperButton'); + return buttonElement; + }; + + if (controlElement instanceof HTMLInputElement) { + const isColorInput = controlElement.type === 'color'; + controlElement.style.height = isColorInput ? '30px' : '32px'; + controlElement.style.width = isColorInput ? '34px' : '100%'; + controlElement.style.minWidth = isColorInput ? '34px' : '0'; + controlElement.style.padding = isColorInput ? '3px 4px' : '6px 8px'; + applyTuningSlotStyles( + controlElement, + isColorInput ? 'colorInput' : 'numberInput', + ); + } else if (controlElement instanceof HTMLSelectElement) { + controlElement.style.height = '32px'; + controlElement.style.padding = '6px 8px'; + applyTuningSlotStyles(controlElement, 'select'); + } else { + controlElement.style.minHeight = '54px'; + controlElement.style.padding = '8px'; + controlElement.style.resize = 'vertical'; + applyTuningSlotStyles(controlElement, 'textarea'); + } + + const isNumberInput = + controlElement instanceof HTMLInputElement && + controlElement.type === 'number'; + const isColorInput = + controlElement instanceof HTMLInputElement && + controlElement.type === 'color'; + controlWrapElement.style.display = 'grid'; + controlWrapElement.style.gridTemplateColumns = isColorInput + ? suffixText + ? '34px minmax(0, 1fr)' + : '34px' + : isNumberInput + ? suffixText + ? 'minmax(0, 1fr) auto auto auto' + : 'minmax(0, 1fr) auto auto' + : suffixText + ? 'minmax(0, 1fr) auto' + : '1fr'; + controlWrapElement.style.alignItems = 'center'; + controlWrapElement.style.gap = '6px'; + controlWrapElement.appendChild(controlElement); + + if (isNumberInput) { + controlWrapElement.appendChild(createStepperButton('Decrement', -1)); + controlWrapElement.appendChild(createStepperButton('Increment', 1)); + } + + if (suffixText) { + suffixElement.textContent = suffixText; + suffixElement.style.fontSize = '12px'; + suffixElement.style.color = '#475569'; + if (isColorInput) { + suffixElement.setAttribute('data-agentic-react-color-value', 'true'); + suffixElement.style.minWidth = '0'; + suffixElement.style.overflow = 'hidden'; + suffixElement.style.textOverflow = 'ellipsis'; + suffixElement.style.whiteSpace = 'nowrap'; + suffixElement.style.fontFamily = + 'ui-monospace, SFMono-Regular, monospace'; + controlElement.addEventListener('input', () => { + suffixElement.textContent = hexToRgbaString(controlElement.value); + }); + controlElement.addEventListener('change', () => { + suffixElement.textContent = hexToRgbaString(controlElement.value); + }); + } + applyTuningSlotStyles(suffixElement, 'suffix'); + controlWrapElement.appendChild(suffixElement); + } + + rowElement.appendChild(labelElement); + rowElement.appendChild(controlWrapElement); + applyTuningSlotStyles(rowElement, 'row'); + applyTuningSlotStyles(labelElement, 'label'); + applyTuningSlotStyles(controlWrapElement, 'controlWrap'); + return rowElement; + }; + + const createTuningInput = ( + name: string, + value: string, + inputType = 'text', + ) => { + const inputElement = document.createElement('input'); + inputElement.type = inputType; + inputElement.name = name; + inputElement.value = value; + return inputElement; + }; + + const createTuningNumberInput = ( + name: string, + value: number, + min: number, + max: number, + step: number, + ) => { + const inputElement = createTuningInput(name, String(value), 'number'); + inputElement.min = String(min); + inputElement.max = String(max); + inputElement.step = String(step); + return inputElement; + }; + + const createTuningSelect = ( + name: string, + values: string[], + selectedValue: string, + ) => { + const selectElement = document.createElement('select'); + selectElement.name = name; + + for (const value of values) { + const optionElement = document.createElement('option'); + optionElement.value = value; + optionElement.textContent = value; + selectElement.appendChild(optionElement); + } + + selectElement.value = values.includes(selectedValue) + ? selectedValue + : values[0] || ''; + return selectElement; + }; + + const openTuningModal = (session: TuningSession) => { + closeTuningModal(); + activeTuningSession = session; + tuningBodyElement.replaceChildren(); + + const computedStyle = window.getComputedStyle(session.element); + const targetLabel = buildTuningTargetLabel(session.selectionContext); + const targetTagName = session.element.tagName.toLowerCase(); + const supportsTextTuning = hasTuneableText(session.element); + const supportsElementTuning = session.element instanceof HTMLElement; + const supportsLayoutTuning = + supportsElementTuning && + hasTuneableLayout(session.element, computedStyle); + const targetRect = session.element.getBoundingClientRect(); + const tuningContext = buildTuningModalContext( + session, + computedStyle, + targetLabel, + targetTagName, + ); + const tuningActions = buildTuningModalActions(); + + tuningTitleElement.textContent = `Tune ${targetLabel}`; + + const targetTagElement = document.createElement('div'); + targetTagElement.setAttribute( + 'data-agentic-react-tuning-target-tag', + 'true', + ); + targetTagElement.textContent = targetTagName; + targetTagElement.style.display = 'inline-flex'; + targetTagElement.style.alignItems = 'center'; + targetTagElement.style.width = 'fit-content'; + targetTagElement.style.maxWidth = '100%'; + targetTagElement.style.marginBottom = '2px'; + targetTagElement.style.padding = '4px 8px'; + targetTagElement.style.border = '1px solid rgba(15, 23, 42, 0.1)'; + targetTagElement.style.borderRadius = '999px'; + targetTagElement.style.background = '#f8fafc'; + targetTagElement.style.color = '#0f172a'; + targetTagElement.style.fontFamily = + 'ui-monospace, SFMono-Regular, monospace'; + targetTagElement.style.fontSize = '12px'; + targetTagElement.style.fontWeight = '700'; + applyTuningSlotStyles(targetTagElement, 'targetTag'); + tuningBodyElement.appendChild(targetTagElement); + + const customPromptFormElement = document.createElement('form'); + const customPromptInputElement = document.createElement('textarea'); + const customPromptButtonElement = document.createElement('button'); + customPromptFormElement.style.display = 'grid'; + customPromptFormElement.style.gridTemplateColumns = 'minmax(0, 1fr) auto'; + customPromptFormElement.style.gap = '8px'; + customPromptFormElement.style.alignItems = 'end'; + customPromptInputElement.name = 'custom-tuning-prompt'; + customPromptInputElement.placeholder = 'Describe these changes...'; + customPromptInputElement.setAttribute( + 'aria-label', + 'Describe custom tuning changes', + ); + styleTuningControl(customPromptInputElement); + customPromptInputElement.style.minHeight = '42px'; + customPromptInputElement.style.padding = '8px'; + customPromptInputElement.style.resize = 'vertical'; + applyTuningSlotStyles( + customPromptInputElement, + 'textarea', + 'customPromptInput', + ); + customPromptButtonElement.type = 'submit'; + customPromptButtonElement.textContent = 'Add'; + customPromptButtonElement.style.height = '32px'; + customPromptButtonElement.style.padding = '0 12px'; + customPromptButtonElement.style.border = + 'var(--agentic-react-tuning-primary-button-border, 1px solid rgba(15, 23, 42, 0.14))'; + customPromptButtonElement.style.borderRadius = + 'var(--agentic-react-tuning-control-radius, 8px)'; + customPromptButtonElement.style.background = + 'var(--agentic-react-tuning-primary-button-background, #111827)'; + customPromptButtonElement.style.color = + 'var(--agentic-react-tuning-primary-button-color, #ffffff)'; + customPromptButtonElement.style.cursor = 'pointer'; + customPromptButtonElement.style.fontSize = '12px'; + customPromptButtonElement.style.fontWeight = '700'; + applyTuningSlotStyles(customPromptButtonElement, 'customPromptButton'); + applyTuningSlotStyles(customPromptFormElement, 'customPromptForm'); + customPromptFormElement.addEventListener('submit', (event) => { + event.preventDefault(); + const customPrompt = customPromptInputElement.value.trim(); + if (!customPrompt) return; + addTuningPrompt(customPrompt); + customPromptInputElement.value = ''; + }); + customPromptFormElement.appendChild(customPromptInputElement); + customPromptFormElement.appendChild(customPromptButtonElement); + tuningBodyElement.appendChild(customPromptFormElement); + + const beforeFieldsElement = document.createElement('div'); + beforeFieldsElement.setAttribute( + 'data-agentic-react-tuning-slot', + 'before-fields', + ); + renderTuningExtensionSlot( + 'beforeFields', + beforeFieldsElement, + tuningContext, + tuningActions, + ); + if (beforeFieldsElement.hasChildNodes()) { + tuningBodyElement.appendChild(beforeFieldsElement); + } + + tuningBodyElement.appendChild(createTuningSectionTitle('Visual')); + + if (supportsTextTuning) { + const textColorInputElement = createTuningInput( + 'text-color', + rgbStringToHex(computedStyle.color), + 'color', + ); + textColorInputElement.addEventListener('change', () => { + addTuningPrompt( + `Change ${targetLabel} text color to ${hexToRgbString( + textColorInputElement.value, + )}.`, + ); + }); + tuningBodyElement.appendChild( + createTuningRow( + 'Text color', + textColorInputElement, + cssColorToRgbaString(computedStyle.color, { + r: 17, + g: 24, + b: 39, + a: 1, + }), + ), + ); + } + + if (supportsElementTuning) { + const backgroundColorInputElement = createTuningInput( + 'background-color', + rgbStringToHex(computedStyle.backgroundColor), + 'color', + ); + backgroundColorInputElement.addEventListener('change', () => { + addTuningPrompt( + `Change ${targetLabel} background color to ${hexToRgbString( + backgroundColorInputElement.value, + )}.`, + ); + }); + tuningBodyElement.appendChild( + createTuningRow( + 'Background', + backgroundColorInputElement, + cssColorToRgbaString(computedStyle.backgroundColor), + ), + ); + + const opacityInputElement = createTuningNumberInput( + 'opacity', + Number(normalizeCssNumber(computedStyle.opacity, 1).toFixed(2)), + 0, + 1, + 0.05, + ); + opacityInputElement.addEventListener('change', () => { + const opacity = Number( + clampNumber(Number(opacityInputElement.value) || 1, 0, 1).toFixed(2), + ); + opacityInputElement.value = String(opacity); + addTuningPrompt(`Change ${targetLabel} opacity to ${opacity}.`); + }); + tuningBodyElement.appendChild( + createTuningRow('Opacity', opacityInputElement), + ); + } + + if (supportsTextTuning) { + tuningBodyElement.appendChild(createTuningSectionTitle('Typography')); + const currentFontFamily = + computedStyle.fontFamily.split(',')[0]?.replace(/["']/g, '').trim() || + 'inherit'; + const fontFamilySelectElement = createTuningSelect( + 'font-family', + Array.from( + new Set([currentFontFamily, ...TUNING_FONT_FAMILY_OPTIONS]), + ).filter(Boolean), + currentFontFamily, + ); + fontFamilySelectElement.addEventListener('change', () => { + addTuningPrompt( + `Change ${targetLabel} font family to ${fontFamilySelectElement.value}.`, + ); + }); + tuningBodyElement.appendChild( + createTuningRow('Font', fontFamilySelectElement), + ); + + const fontSizeInputElement = document.createElement('input'); + fontSizeInputElement.type = 'number'; + fontSizeInputElement.name = 'font-size'; + fontSizeInputElement.min = '8'; + fontSizeInputElement.max = '72'; + fontSizeInputElement.step = '1'; + fontSizeInputElement.value = String( + Math.round(Number.parseFloat(computedStyle.fontSize) || 16), + ); + fontSizeInputElement.addEventListener('change', () => { + const fontSize = clampNumber( + Number(fontSizeInputElement.value) || 16, + 8, + 72, + ); + fontSizeInputElement.value = String(fontSize); + addTuningPrompt(`Change ${targetLabel} font size to ${fontSize}px.`); + }); + tuningBodyElement.appendChild( + createTuningRow('Font size', fontSizeInputElement, 'px'), + ); + + const fontWeight = computedStyle.fontWeight || '400'; + const fontWeightSelectElement = createTuningSelect( + 'font-weight', + Array.from(new Set([fontWeight, ...TUNING_FONT_WEIGHT_OPTIONS])), + fontWeight, + ); + fontWeightSelectElement.addEventListener('change', () => { + addTuningPrompt( + `Change ${targetLabel} font weight to ${fontWeightSelectElement.value}.`, + ); + }); + tuningBodyElement.appendChild( + createTuningRow('Font weight', fontWeightSelectElement), + ); + } + + if (supportsElementTuning) { + tuningBodyElement.appendChild(createTuningSectionTitle('Box')); + const widthInputElement = createTuningNumberInput( + 'width', + Math.round( + targetRect.width || normalizeCssNumber(computedStyle.width, 0), + ), + 0, + 2000, + 1, + ); + widthInputElement.addEventListener('change', () => { + const width = clampNumber( + Number(widthInputElement.value) || 0, + 0, + 2000, + ); + widthInputElement.value = String(width); + addTuningPrompt(`Change ${targetLabel} width to ${width}px.`); + }); + tuningBodyElement.appendChild( + createTuningRow('Width', widthInputElement, 'px'), + ); + + const heightInputElement = createTuningNumberInput( + 'height', + Math.round( + targetRect.height || normalizeCssNumber(computedStyle.height, 0), + ), + 0, + 2000, + 1, + ); + heightInputElement.addEventListener('change', () => { + const height = clampNumber( + Number(heightInputElement.value) || 0, + 0, + 2000, + ); + heightInputElement.value = String(height); + addTuningPrompt(`Change ${targetLabel} height to ${height}px.`); + }); + tuningBodyElement.appendChild( + createTuningRow('Height', heightInputElement, 'px'), + ); + + const paddingInputElement = createTuningNumberInput( + 'padding', + Math.round(normalizeCssNumber(computedStyle.paddingTop, 0)), + 0, + 240, + 1, + ); + paddingInputElement.addEventListener('change', () => { + const padding = clampNumber( + Number(paddingInputElement.value) || 0, + 0, + 240, + ); + paddingInputElement.value = String(padding); + addTuningPrompt(`Change ${targetLabel} padding to ${padding}px.`); + }); + tuningBodyElement.appendChild( + createTuningRow('Padding', paddingInputElement, 'px'), + ); + + const marginInputElement = createTuningNumberInput( + 'margin', + Math.round(normalizeCssNumber(computedStyle.marginTop, 0)), + -240, + 240, + 1, + ); + marginInputElement.addEventListener('change', () => { + const margin = clampNumber( + Number(marginInputElement.value) || 0, + -240, + 240, + ); + marginInputElement.value = String(margin); + addTuningPrompt(`Change ${targetLabel} margin to ${margin}px.`); + }); + tuningBodyElement.appendChild( + createTuningRow('Margin', marginInputElement, 'px'), + ); + } + + if (supportsLayoutTuning) { + tuningBodyElement.appendChild(createTuningSectionTitle('Layout')); + const displaySelectElement = createTuningSelect( + 'display', + Array.from(new Set([computedStyle.display, ...TUNING_DISPLAY_OPTIONS])), + computedStyle.display, + ); + displaySelectElement.addEventListener('change', () => { + addTuningPrompt( + `Change ${targetLabel} display layout to ${displaySelectElement.value}.`, + ); + }); + tuningBodyElement.appendChild( + createTuningRow('Display', displaySelectElement), + ); + + const directionSelectElement = createTuningSelect( + 'layout-direction', + TUNING_FLEX_DIRECTION_OPTIONS, + computedStyle.flexDirection, + ); + directionSelectElement.addEventListener('change', () => { + addTuningPrompt( + `Change ${targetLabel} layout direction to ${directionSelectElement.value}.`, + ); + }); + tuningBodyElement.appendChild( + createTuningRow('Layout direction', directionSelectElement), + ); + + const distributionSelectElement = createTuningSelect( + 'distribution', + Array.from( + new Set([computedStyle.justifyContent, ...TUNING_JUSTIFY_OPTIONS]), + ), + computedStyle.justifyContent, + ); + distributionSelectElement.addEventListener('change', () => { + addTuningPrompt( + `Change ${targetLabel} distribution to ${distributionSelectElement.value}.`, + ); + }); + tuningBodyElement.appendChild( + createTuningRow('Distribution', distributionSelectElement), + ); + + const alignmentSelectElement = createTuningSelect( + 'alignment', + Array.from( + new Set([computedStyle.alignItems, ...TUNING_ALIGN_OPTIONS]), + ), + computedStyle.alignItems, + ); + alignmentSelectElement.addEventListener('change', () => { + addTuningPrompt( + `Change ${targetLabel} alignment to ${alignmentSelectElement.value}.`, + ); + }); + tuningBodyElement.appendChild( + createTuningRow('Alignment', alignmentSelectElement), + ); + + const spacingInputElement = createTuningNumberInput( + 'spacing', + Math.round(normalizeCssNumber(computedStyle.gap, 0)), + 0, + 240, + 1, + ); + spacingInputElement.addEventListener('change', () => { + const spacing = clampNumber( + Number(spacingInputElement.value) || 0, + 0, + 240, + ); + spacingInputElement.value = String(spacing); + addTuningPrompt(`Change ${targetLabel} spacing to ${spacing}px.`); + }); + tuningBodyElement.appendChild( + createTuningRow('Spacing', spacingInputElement, 'px'), + ); + } + + const afterFieldsElement = document.createElement('div'); + afterFieldsElement.setAttribute( + 'data-agentic-react-tuning-slot', + 'after-fields', + ); + renderTuningExtensionSlot( + 'afterFields', + afterFieldsElement, + tuningContext, + tuningActions, + ); + if (afterFieldsElement.hasChildNodes()) { + tuningBodyElement.appendChild(afterFieldsElement); + } + + const footerElement = document.createElement('div'); + footerElement.setAttribute('data-agentic-react-tuning-slot', 'footer'); + renderTuningExtensionSlot( + 'footer', + footerElement, + tuningContext, + tuningActions, + ); + if (footerElement.hasChildNodes()) { + tuningBodyElement.appendChild(footerElement); + } + + if (!supportsTextTuning && !supportsElementTuning) { + const emptyElement = document.createElement('div'); + emptyElement.textContent = 'No tuning options available.'; + emptyElement.style.fontSize = '12px'; + emptyElement.style.color = '#475569'; + tuningBodyElement.appendChild(emptyElement); + } + + renderTuningExtensionWrappers(tuningContext, tuningActions); + tuningModalElement.style.zIndex = String(toolkitConfig.zIndex + 1); + tuningModalElement.style.display = 'block'; + positionTuningPanel(); + }; + + const hideDimOverlay = () => { + dimTargetElement = null; + if (dimHideTimeout !== null) { + window.clearTimeout(dimHideTimeout); + dimHideTimeout = null; + } + + for (const dimElement of dimElements) { + dimElement.style.display = 'none'; + } + }; + + const showDimForElement = (element: Element) => { + const rect = element.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const topHeight = Math.max(0, rect.top); + const bottomTop = Math.min(viewportHeight, Math.max(0, rect.bottom)); + const leftWidth = Math.max(0, rect.left); + const rightLeft = Math.min(viewportWidth, Math.max(0, rect.right)); + const middleTop = Math.max(0, rect.top); + const middleHeight = Math.max( + 0, + Math.min(viewportHeight, rect.bottom) - middleTop, + ); + + dimTargetElement = element; + + const [topElement, rightElement, bottomElement, leftElement] = dimElements; + topElement.style.display = 'block'; + topElement.style.left = '0px'; + topElement.style.top = '0px'; + topElement.style.width = `${viewportWidth}px`; + topElement.style.height = `${topHeight}px`; + + rightElement.style.display = 'block'; + rightElement.style.left = `${rightLeft}px`; + rightElement.style.top = `${middleTop}px`; + rightElement.style.width = `${Math.max(0, viewportWidth - rightLeft)}px`; + rightElement.style.height = `${middleHeight}px`; + + bottomElement.style.display = 'block'; + bottomElement.style.left = '0px'; + bottomElement.style.top = `${bottomTop}px`; + bottomElement.style.width = `${viewportWidth}px`; + bottomElement.style.height = `${Math.max(0, viewportHeight - bottomTop)}px`; + + leftElement.style.display = 'block'; + leftElement.style.left = '0px'; + leftElement.style.top = `${middleTop}px`; + leftElement.style.width = `${leftWidth}px`; + leftElement.style.height = `${middleHeight}px`; + }; + + const scheduleDimHide = () => { + if (dimHideTimeout !== null) { + window.clearTimeout(dimHideTimeout); + } + + dimHideTimeout = window.setTimeout(() => { + hideDimOverlay(); + }, SELECTION_CONFIRMATION_MS); + }; + + const hideHoverOverlay = () => { + hoverElement.style.display = 'none'; + hoverLabelElement.textContent = ''; + hoverLabelTargetElement = null; + hoverLabelRequestId += 1; + }; + + const showHoverForElement = (element: Element | null) => { + if (!element || toolkitRootElement.contains(element)) { + hideHoverOverlay(); + if (isSelectionMode) { + hideDimOverlay(); + } + return; + } + + const rect = element.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(element); + hoverElement.style.display = 'block'; + hoverElement.style.borderRadius = computedStyle.borderRadius || '6px'; + updateOverlayLabelForElement(hoverLabelElement, element, rect); + if (hoverLabelTargetElement !== element) { + hoverLabelTargetElement = element; + hoverLabelRequestId += 1; + const requestId = hoverLabelRequestId; + void buildSelectionContextForElement(element) + .then((selectionContext) => { + if ( + requestId !== hoverLabelRequestId || + hoverLabelTargetElement !== element || + hoverElement.style.display === 'none' + ) { + return null; + } + + updateOverlayLabelForElement( + hoverLabelElement, + element, + element.getBoundingClientRect(), + selectionContext, + ); + + return enrichSelectionContextSourceLocation(selectionContext); + }) + .then((selectionContext) => { + if (!selectionContext) { + return; + } + + if ( + requestId !== hoverLabelRequestId || + hoverLabelTargetElement !== element || + hoverElement.style.display === 'none' + ) { + return; + } + + updateOverlayLabelForElement( + hoverLabelElement, + element, + element.getBoundingClientRect(), + selectionContext, + ); + }) + .catch(() => { + // Best effort only; the immediate DOM label remains visible. + }); + } + applyHoverContrastForElement(hoverElement, element); + applyElementRect(hoverElement, rect); + showDimForElement(element); + }; + + const hideSelectedOverlay = () => { + selectedTargetElement = null; + selectedElement.style.display = 'none'; + selectedLabelElement.textContent = ''; + selectedElement.classList.remove('agentic-react-selection-pulse'); + if (selectedPulseTimeout !== null) { + window.clearTimeout(selectedPulseTimeout); + selectedPulseTimeout = null; + } + }; + + const createMultiSelectedOverlay = ( + element: Element, + selectionContext: SelectionContext, + ) => { + const overlayElement = document.createElement('div'); + const labelElement = document.createElement('div'); + const actions = createOverlayActions(); + + overlayElement.setAttribute('data-agentic-react-selected', 'true'); + overlayElement.setAttribute('data-agentic-react-multi-selected', 'true'); + overlayElement.style.position = 'fixed'; + overlayElement.style.pointerEvents = 'none'; + overlayElement.style.display = 'none'; + overlayElement.style.background = 'transparent'; + overlayElement.style.zIndex = String(Math.max(0, toolkitConfig.zIndex - 1)); + overlayElement.style.setProperty( + '--agentic-react-selection-accent', + toolkitConfig.accentColor, + ); + + labelElement.setAttribute('data-agentic-react-selected-label', 'true'); + labelElement.style.position = 'absolute'; + labelElement.style.pointerEvents = 'none'; + labelElement.style.maxWidth = 'min(260px, calc(100vw - 24px))'; + labelElement.style.overflow = 'hidden'; + labelElement.style.textOverflow = 'ellipsis'; + labelElement.style.whiteSpace = 'nowrap'; + + overlayElement.appendChild(labelElement); + overlayElement.appendChild(actions.actionsElement); + document.body.appendChild(overlayElement); + + const overlay = { + element, + overlayElement, + labelElement, + actions, + selectionContext, + pulseTimeout: null, + }; + + actions.tuneButtonElement.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + openTuningModal({ + anchorElement: actions.tuneButtonElement, + element, + selectionContext: overlay.selectionContext, + setSelectionContext: (nextSelectionContext) => { + multiSelectionContexts = multiSelectionContexts.map((context) => + context === overlay.selectionContext + ? nextSelectionContext + : context, + ); + overlay.selectionContext = nextSelectionContext; + }, + }); + }); + + actions.deleteButtonElement.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + removeMultiSelectedOverlay(overlay); + updateStatus( + `${multiSelectionContexts.length} selected. Continue selecting or click Done.`, + ); + }); + + return overlay; + }; + + const updateMultiSelectedOverlay = ( + overlay: (typeof multiSelectedOverlays)[number], + ) => { + const rect = overlay.element.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(overlay.element); + overlay.overlayElement.style.display = 'block'; + overlay.overlayElement.style.borderRadius = + computedStyle.borderRadius || '6px'; + updateOverlayLabelForElement( + overlay.labelElement, + overlay.element, + rect, + overlay.selectionContext, + ); + applyElementRect(overlay.overlayElement, rect); + }; + + const pulseMultiSelectedOverlay = ( + overlay: (typeof multiSelectedOverlays)[number], + ) => { + overlay.overlayElement.classList.remove('agentic-react-selection-pulse'); + void overlay.overlayElement.offsetWidth; + overlay.overlayElement.classList.add('agentic-react-selection-pulse'); + + if (overlay.pulseTimeout !== null) { + window.clearTimeout(overlay.pulseTimeout); + } + overlay.pulseTimeout = window.setTimeout(() => { + overlay.overlayElement.classList.remove('agentic-react-selection-pulse'); + overlay.pulseTimeout = null; + }, SELECTION_CONFIRMATION_MS); + }; + + const showMultiSelectedForElement = ( + element: Element, + selectionContext: SelectionContext, + shouldPulse = false, + ) => { + if (toolkitRootElement.contains(element)) { + return; + } + + let overlay = multiSelectedOverlays.find( + (candidateOverlay) => candidateOverlay.element === element, + ); + if (!overlay) { + overlay = createMultiSelectedOverlay(element, selectionContext); + multiSelectedOverlays.push(overlay); + } else { + overlay.selectionContext = selectionContext; + } + + updateMultiSelectedOverlay(overlay); + + if (shouldPulse) { + pulseMultiSelectedOverlay(overlay); + } + }; + + const clearMultiSelectedOverlays = () => { + for (const overlay of multiSelectedOverlays) { + if (overlay.pulseTimeout !== null) { + window.clearTimeout(overlay.pulseTimeout); + } + overlay.overlayElement.remove(); + } + multiSelectedOverlays = []; + }; + + const removeMultiSelectedOverlay = ( + overlay: (typeof multiSelectedOverlays)[number], + ) => { + if (overlay.pulseTimeout !== null) { + window.clearTimeout(overlay.pulseTimeout); + } + overlay.overlayElement.remove(); + multiSelectedOverlays = multiSelectedOverlays.filter( + (candidateOverlay) => candidateOverlay !== overlay, + ); + multiSelectionContexts = multiSelectionContexts.filter( + (selectionContext) => + selectionContext !== overlay.selectionContext && + selectionContext.selector !== overlay.selectionContext.selector, + ); + renderMultiSelectionControls(); + }; + + const clearMultiSelections = () => { + multiSelectionContexts = []; + clearMultiSelectedOverlays(); + closeTuningModal(); + renderMultiSelectionControls(); + }; + + const showSelectedForElement = ( + element: Element, + shouldPulse = false, + selectionContext: SelectionContext | null = lastSelectionContext, + ) => { + if (toolkitRootElement.contains(element)) { + hideSelectedOverlay(); + return; + } + + const rect = element.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(element); + selectedTargetElement = element; + selectedElement.style.display = 'block'; + selectedElement.style.borderRadius = computedStyle.borderRadius || '6px'; + updateOverlayLabelForElement( + selectedLabelElement, + element, + rect, + selectionContext, + ); + applyElementRect(selectedElement, rect); + + if (!shouldPulse) { + return; + } + + selectedElement.classList.remove('agentic-react-selection-pulse'); + void selectedElement.offsetWidth; + selectedElement.classList.add('agentic-react-selection-pulse'); + + if (selectedPulseTimeout !== null) { + window.clearTimeout(selectedPulseTimeout); + } + selectedPulseTimeout = window.setTimeout(() => { + selectedElement.classList.remove('agentic-react-selection-pulse'); + selectedPulseTimeout = null; + }, SELECTION_CONFIRMATION_MS); + }; + + const updateSelectionOverlays = () => { + if (selectedTargetElement?.isConnected) { + showSelectedForElement(selectedTargetElement); + } else if (selectedTargetElement) { + hideSelectedOverlay(); + } + + multiSelectedOverlays = multiSelectedOverlays.filter((overlay) => { + if (!overlay.element.isConnected) { + if (overlay.pulseTimeout !== null) { + window.clearTimeout(overlay.pulseTimeout); + } + overlay.overlayElement.remove(); + return false; + } + + updateMultiSelectedOverlay(overlay); + return true; + }); + + if (dimTargetElement?.isConnected) { + showDimForElement(dimTargetElement); + } else if (dimTargetElement) { + hideDimOverlay(); + } + + if (activeTuningSession) { + positionTuningPanel(); + } + }; + + const isToolkitElement = (element: Element) => + toolkitRootElement.contains(element) || + tuningModalElement.contains(element) || + hoverElement.contains(element) || + selectedElement.contains(element) || + multiSelectedOverlays.some(({ overlayElement }) => + overlayElement.contains(element), + ) || + dimElements.some((dimElement) => dimElement.contains(element)); + + const isToolkitNode = (node: Node) => + toolkitRootElement.contains(node) || + tuningModalElement.contains(node) || + hoverElement.contains(node) || + selectedElement.contains(node) || + multiSelectedOverlays.some(({ overlayElement }) => + overlayElement.contains(node), + ) || + dimElements.some((dimElement) => dimElement.contains(node)); + + const getSelectableElement = (clientX: number, clientY: number) => + getSelectableElementAtPosition(clientX, clientY, isToolkitElement); + + const onMouseMove = (mouseEvent: MouseEvent) => { + if (!isSelectionMode) return; + + showHoverForElement( + getSelectableElement(mouseEvent.clientX, mouseEvent.clientY), + ); + }; + + const onSelect = async (mouseEvent: MouseEvent) => { + const clickTarget = mouseEvent.target; + if (!isSelectionMode) { + if ( + isPanelOpen && + clickTarget instanceof Node && + !toolkitRootElement.contains(clickTarget) + ) { + isPanelOpen = false; + renderPanelVisibility(); + } + return; + } + + if (clickTarget instanceof Node && isToolkitNode(clickTarget)) { + return; + } + + const selectedTarget = getSelectableElement( + mouseEvent.clientX, + mouseEvent.clientY, + ); + + if (!selectedTarget) { + return; + } + + mouseEvent.preventDefault(); + mouseEvent.stopPropagation(); + mouseEvent.stopImmediatePropagation(); + + let didCaptureSelection = false; + + try { + const selectionContext = await enrichSelectionContextSourceLocation( + await buildSelectionContextForElement(selectedTarget), + ); + lastSelectionContext = selectionContext; + if (isMultiSelectionMode) { + hideSelectedOverlay(); + showMultiSelectedForElement(selectedTarget, selectionContext, true); + } else { + showSelectedForElement(selectedTarget, true, selectionContext); + } + showDimForElement(selectedTarget); + scheduleDimHide(); + didCaptureSelection = true; + isPanelOpen = true; + renderPanelVisibility(); + const capturedSelectionLabel = `${selectionContext.componentName || 'selection'}${ + selectionContext.selector ? ` (${selectionContext.selector})` : '' + }`; + if (isMultiSelectionMode) { + const existingSelectionIndex = multiSelectionContexts.findIndex( + (context) => context.selector === selectionContext.selector, + ); + if (existingSelectionIndex >= 0) { + multiSelectionContexts[existingSelectionIndex] = selectionContext; + } else { + multiSelectionContexts.push(selectionContext); + } + renderMultiSelectionControls(); + updateStatus( + `Added ${capturedSelectionLabel}. ${multiSelectionContexts.length} selected. Click Done to copy all.`, + ); + } else { + const copyResult = await copyLastSelectionContext('text'); + updateStatus( + copyResult.success + ? `Captured and copied ${capturedSelectionLabel}` + : `Captured ${capturedSelectionLabel}. ${copyResult.error || 'Failed to copy context.'}`, + ); + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Failed to capture selection context'; + updateStatus(message); + } finally { + if (isMultiSelectionMode && didCaptureSelection) { + isSelectionMode = true; + multiselectButtonElement.textContent = 'Selecting...'; + launcherButtonElement.setAttribute( + 'aria-label', + 'Multiselect mode active for Agentic React toolkit', + ); + } else { + isSelectionMode = false; + selectButtonElement.textContent = 'Select'; + multiselectButtonElement.textContent = 'Multiselect'; + doneButtonElement.style.display = 'none'; + launcherButtonElement.setAttribute( + 'aria-label', + 'Open Agentic React toolkit', + ); + } + hideHoverOverlay(); + if (!didCaptureSelection) { + hideDimOverlay(); + } + clearSelectableElementCache(); + } + }; + + const enterSelectionMode = () => { + isSelectionMode = true; + isMultiSelectionMode = false; + clearMultiSelections(); + clearSelectableElementCache(); + hideSelectedOverlay(); + isPanelOpen = true; + renderPanelVisibility(); + selectButtonElement.textContent = 'Selecting...'; + multiselectButtonElement.textContent = 'Multiselect'; + doneButtonElement.style.display = 'none'; + launcherButtonElement.setAttribute( + 'aria-label', + 'Selection mode active for Agentic React toolkit', + ); + updateStatus( + 'Selection mode enabled. Click any element to capture context.', + ); + }; + + const enterMultiSelectionMode = () => { + isSelectionMode = true; + isMultiSelectionMode = true; + clearMultiSelections(); + clearSelectableElementCache(); + hideSelectedOverlay(); + isPanelOpen = true; + renderPanelVisibility(); + selectButtonElement.textContent = 'Select'; + multiselectButtonElement.textContent = 'Selecting...'; + doneButtonElement.style.display = 'block'; + launcherButtonElement.setAttribute( + 'aria-label', + 'Multiselect mode active for Agentic React toolkit', + ); + updateStatus('Multiselect enabled. Select elements, then click Done.'); + }; + + const exitSelectionMode = () => { + isSelectionMode = false; + isMultiSelectionMode = false; + clearMultiSelections(); + selectButtonElement.textContent = 'Select'; + multiselectButtonElement.textContent = 'Multiselect'; + doneButtonElement.style.display = 'none'; + launcherButtonElement.setAttribute( + 'aria-label', + 'Open Agentic React toolkit', + ); + closeTuningModal(); + hideHoverOverlay(); + hideDimOverlay(); + clearSelectableElementCache(); + }; + + const togglePanel = () => { + isPanelOpen = !isPanelOpen; + if (!isPanelOpen) { + exitSelectionMode(); + } + renderPanelVisibility(); + }; + + const showToolkit = () => { + isToolkitVisible = true; + toolkitRootElement.style.display = toolkitConfig.enabled ? 'flex' : 'none'; + }; + + const hideToolkit = () => { + isToolkitVisible = false; + toolkitRootElement.style.display = 'none'; + exitSelectionMode(); + hideSelectedOverlay(); + closeTuningModal(); + }; + + const setToolkitConfig = (config: Partial) => { + toolkitConfig = mergeToolkitConfig(toolkitConfig, config); + updateToolkitStyle(); + + if (!toolkitConfig.enabled) { + hideToolkit(); + } else if (isToolkitVisible) { + showToolkit(); + } + + return toolkitConfig; + }; + + const getLastSelectionContext = (): SelectionContext | null => { + return lastSelectionContext; + }; + + const enrichSelectionContextSourceSnippets = async ( + selectionContext: SelectionContext, + ): Promise => { + let enrichedSelectionContext = + await enrichSelectionContextSourceLocation(selectionContext); + + if (enrichedSelectionContext.sourceSnippets.length > 0) { + return enrichedSelectionContext; + } + + const sourceSnippets: SelectionContext['sourceSnippets'] = []; + const resolvedSources = enrichedSelectionContext.resolvedSources.slice( + 0, + DEFAULT_MAX_SNIPPET_FILES, + ); + const normalizedSourceRoot = getNormalizedSourceRoot(); + + for (const resolvedSource of resolvedSources) { + if ( + !isAllowedProjectSourcePath( + resolvedSource.filePath, + normalizedSourceRoot, + ) + ) { + continue; + } + + if (!resolvedSource.lineNumber || resolvedSource.lineNumber < 1) { + continue; + } + + const candidateUrls = buildBrowserSourceCandidates( + resolvedSource.filePath, + normalizedSourceRoot, + ); + if (candidateUrls.length === 0) { + continue; + } + + for (const candidateUrl of candidateUrls) { + try { + const response = await fetch(candidateUrl, { cache: 'no-store' }); + if (!response.ok) { + continue; + } + + const sourceTextResponse = await response.text(); + const sourceText = + normalizeSourceTextFromViteResponse(sourceTextResponse); + if (!sourceText) { + continue; + } + const snippetPayload = buildSourceSnippet( + sourceText, + resolvedSource.lineNumber, + DEFAULT_SNIPPET_CONTEXT_LINES, + ); + + if (!snippetPayload) { + continue; + } + + sourceSnippets.push({ + filePath: + toAbsoluteSourcePath( + resolvedSource.filePath, + normalizedSourceRoot, + ) ?? resolvedSource.filePath, + startLine: snippetPayload.startLine, + endLine: snippetPayload.endLine, + snippet: snippetPayload.snippet, + }); + break; + } catch (_error) { + // best effort only + } + } + } + + if (sourceSnippets.length === 0) { + return enrichedSelectionContext; + } + + enrichedSelectionContext = { + ...enrichedSelectionContext, + sourceSnippets, + }; + return { + ...enrichedSelectionContext, + sourcePreview: buildSelectionSourcePreview(enrichedSelectionContext), + }; + }; + + const copySelectionContexts = async ( + selectionContexts: SelectionContext[], + ): Promise<{ + success: boolean; + copied: boolean; + contexts: SelectionContext[]; + error?: string; + }> => { + if (selectionContexts.length === 0) { + return { + success: false, + copied: false, + contexts: [], + error: 'No selections found', + }; + } + + const textToCopy = selectionContexts + .map( + (selectionContext, index) => + `Selection ${index + 1}\n${toTextContext(selectionContext)}`, + ) + .join('\n\n---\n\n'); + const copied = await copyText(textToCopy); + + return { + success: copied, + copied, + contexts: selectionContexts, + error: copied ? undefined : 'Failed to copy selections to clipboard', + }; + }; + + const copyLastSelectionContext = async ( + format: 'text' | 'json' = 'text', + ): Promise<{ + success: boolean; + copied: boolean; + format: 'text' | 'json'; + context?: SelectionContext; + error?: string; + }> => { + if (!lastSelectionContext) { + return { + success: false, + copied: false, + format, + error: 'No selection context found', + }; + } + + lastSelectionContext = + await enrichSelectionContextSourceSnippets(lastSelectionContext); + + const textToCopy = + format === 'json' + ? JSON.stringify(lastSelectionContext, null, 2) + : toTextContext(lastSelectionContext); + const copied = await copyText(textToCopy); + + if (!copied) { + return { + success: false, + copied: false, + format, + context: lastSelectionContext, + error: 'Failed to copy context to clipboard', + }; + } + + updateStatus('Copied last selection context'); + + return { + success: true, + copied: true, + format, + context: lastSelectionContext, + }; + }; + + selectedActions.tuneButtonElement.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (!selectedTargetElement || !lastSelectionContext) { + updateStatus('No selection to adjust.'); + return; + } + + openTuningModal({ + anchorElement: selectedActions.tuneButtonElement, + element: selectedTargetElement, + selectionContext: lastSelectionContext, + setSelectionContext: (nextSelectionContext) => { + lastSelectionContext = nextSelectionContext; + }, + }); + }); + + selectedActions.deleteButtonElement.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + lastSelectionContext = null; + hideSelectedOverlay(); + hideDimOverlay(); + closeTuningModal(); + updateStatus('Cleared selected context.'); + }); + + tuningCloseButtonElement.addEventListener('click', () => { + closeTuningModal(); + }); + + tuningModalElement.addEventListener('click', (event) => { + if (event.target === tuningModalElement) { + closeTuningModal(); + } + }); + + launcherButtonElement.addEventListener('click', () => { + togglePanel(); + }); + + selectButtonElement.addEventListener('click', () => { + if (isSelectionMode) { + exitSelectionMode(); + } else { + enterSelectionMode(); + } + }); + + multiselectButtonElement.addEventListener('click', () => { + if (isMultiSelectionMode) { + exitSelectionMode(); + } else { + enterMultiSelectionMode(); + } + }); + + clearAllButtonElement.addEventListener('click', () => { + if (!isMultiSelectionMode) { + return; + } + + if (multiSelectionContexts.length === 0) { + updateStatus('No selections to clear.'); + return; + } + + clearMultiSelections(); + hideDimOverlay(); + updateStatus('Cleared all selections. Continue selecting or click Done.'); + }); + + doneButtonElement.addEventListener('click', () => { + void (async () => { + const selectionContextsToCopy = multiSelectionContexts.slice(); + const copyResult = await copySelectionContexts(selectionContextsToCopy); + const copiedMessage = `Copied ${copyResult.contexts.length} selected ${ + copyResult.contexts.length === 1 ? 'context' : 'contexts' + }`; + if (copyResult.success) { + lastSelectionContext = + copyResult.contexts[copyResult.contexts.length - 1] ?? + lastSelectionContext; + } else { + updateStatus(copyResult.error || 'Failed to copy selections'); + } + exitSelectionMode(); + isPanelOpen = true; + renderPanelVisibility(); + if (copyResult.success) { + updateStatus(copiedMessage); + } + })(); + }); + + const mountToolkit = () => { + if (!document.body) { + return false; + } + + mountSelectionStyles(); + updateToolkitStyle(); + toolkitRootElement.style.display = isToolkitVisible ? 'flex' : 'none'; + + for (const dimElement of dimElements) { + if (!dimElement.isConnected) { + document.body.appendChild(dimElement); + } + } + if (!toolkitRootElement.isConnected) { + document.body.appendChild(toolkitRootElement); + } + if (!hoverElement.isConnected) { + document.body.appendChild(hoverElement); + } + if (!selectedElement.isConnected) { + document.body.appendChild(selectedElement); + } + if (!tuningModalElement.isConnected) { + document.body.appendChild(tuningModalElement); + } + return true; + }; + + if (!mountToolkit()) { + document.addEventListener( + 'DOMContentLoaded', + () => { + mountToolkit(); + }, + { once: true }, + ); + } + + document.addEventListener('mousemove', onMouseMove, true); + document.addEventListener('click', onSelect, true); + document.addEventListener('scroll', updateSelectionOverlays, true); + window.addEventListener('resize', updateSelectionOverlays); + + return { + showToolkit, + hideToolkit, + setToolkitConfig, + enterSelectionMode, + exitSelectionMode, + setSelectionMode: (enabled: boolean) => { + if (enabled) { + enterSelectionMode(); + } else { + exitSelectionMode(); + } + }, + getLastSelectionContext, + copyLastSelectionContext, + registerTuningModalExtension, + }; +}; diff --git a/packages/core/src/core/tools/source_ownership.ts b/packages/core/src/core/tools/source_ownership.ts new file mode 100644 index 0000000..6dded14 --- /dev/null +++ b/packages/core/src/core/tools/source_ownership.ts @@ -0,0 +1,38 @@ +import type { Fiber } from 'bippy'; +import { getSource, isSourceFile, normalizeFileName } from 'bippy/source'; + +const PATH_SEPARATOR_PATTERN = /[/\\]/; + +const normalizeSourceFilePath = (fileName: string): string => { + let normalizedFileName = normalizeFileName(fileName); + normalizedFileName = normalizedFileName.replace( + /^(?:\.\/)?\/?\([a-z][a-z0-9-]*\)\//, + '', + ); + if (normalizedFileName.startsWith('./')) { + normalizedFileName = normalizedFileName.slice(2); + } + return normalizedFileName.replace(/\?.*$/, ''); +}; + +const splitPathSegments = (path: string): string[] => + path.split(PATH_SEPARATOR_PATTERN).filter(Boolean); + +export const isProjectSourceFrame = (fileName: string): boolean => { + if (!isSourceFile(fileName)) { + return false; + } + + const normalizedFileName = normalizeSourceFilePath(fileName); + const pathSegments = splitPathSegments(normalizedFileName); + return ( + !pathSegments.includes('node_modules') && + !pathSegments.includes('.vite') && + !normalizedFileName.includes('/@vite/') + ); +}; + +export const isProjectOwnedFiber = async (fiber: Fiber): Promise => { + const source = await getSource(fiber).catch(() => null); + return Boolean(source?.fileName && isProjectSourceFrame(source.fileName)); +}; diff --git a/src/core/tools/track_wasted_render.ts b/packages/core/src/core/tools/track_wasted_render.ts similarity index 95% rename from src/core/tools/track_wasted_render.ts rename to packages/core/src/core/tools/track_wasted_render.ts index 94ba9ea..2d97268 100644 --- a/src/core/tools/track_wasted_render.ts +++ b/packages/core/src/core/tools/track_wasted_render.ts @@ -7,14 +7,15 @@ import { shouldFilterFiber, traverseProps, } from 'bippy'; -import { store, wastedRenderFiberInfo } from '../../shared/store'; +import { store, wastedRenderFiberInfo } from '../../shared/store.js'; import { getDisplayName, getDisplayNameForFiber, getNearestFiberWithStateNode, isReactElement, -} from '../../shared/util'; -import { flashStateNode } from './util'; +} from '../../shared/util.js'; +import { isProjectOwnedFiber } from './source_ownership.js'; +import { flashStateNode } from './util.js'; const stringifyValueExceptObject = (value: any): string | null => { if (value === null) return 'null'; @@ -329,7 +330,7 @@ export const collectUnnecessaryRender = (fiber: Fiber) => { } }; -export const queryWastedRender = ( +export const queryWastedRender = async ( timeframe: number, { allComponents = false, @@ -343,12 +344,12 @@ export const queryWastedRender = ( start = Date.now() - timeframe * 1000; for (const wastedRender of wastedRenderFiberInfo.values()) { - if ( - wastedRender.collectedAt >= start && - wastedRender.collectedAt <= end && - wastedRender.fiber.return && - (allComponents || window.__REACT_COMPONENTS__.includes(wastedRender.name)) - ) { + const isWithinTimeframe = + wastedRender.collectedAt >= start && wastedRender.collectedAt <= end; + const isIncludedComponent = + allComponents || (await isProjectOwnedFiber(wastedRender.fiber)); + + if (isWithinTimeframe && wastedRender.fiber.return && isIncludedComponent) { result.push(wastedRender); } } diff --git a/src/core/tools/util.ts b/packages/core/src/core/tools/util.ts similarity index 97% rename from src/core/tools/util.ts rename to packages/core/src/core/tools/util.ts index 5c129a0..0d2753e 100644 --- a/src/core/tools/util.ts +++ b/packages/core/src/core/tools/util.ts @@ -1,10 +1,10 @@ import type { Fiber } from 'bippy'; -import { getAllFiberRoots } from '../../shared/util'; +import { getAllFiberRoots } from '../../shared/util.js'; import { findComponentsInFiber, getNearestFiberWithStateNode, -} from '../../shared/util'; -import type { ComponentTreeNode } from '../../types/internal'; +} from '../../shared/util.js'; +import type { ComponentTreeNode } from '../../types/internal.js'; export function getFibersByComponentName(componentName: string): Fiber[] { const result: Fiber[] = []; diff --git a/packages/core/src/global.d.ts b/packages/core/src/global.d.ts new file mode 100644 index 0000000..be1ac27 --- /dev/null +++ b/packages/core/src/global.d.ts @@ -0,0 +1,61 @@ +import type { + AgenticReactConfig, + SelectionContext, + ToolResultValue, + ToolkitConfig, + TuningModalExtension, +} from './shared/types.js'; +import type { WastedRenderFiberInfo } from './types/internal.js'; + +type AgenticReactTools = { + highlightComponent: ( + componentName: string, + options?: { debugMode?: boolean }, + ) => unknown[]; + getComponentTree: (options?: { + allComponents?: boolean; + debugMode?: boolean; + }) => Promise; + getComponentStates: ( + componentName: string, + options?: { debugMode?: boolean }, + ) => unknown; + getUnnecessaryRenderedComponents: ( + timeframe?: number, + options?: { + allComponents?: boolean; + debugMode?: boolean; + }, + ) => Promise; + registerCustomTool: ( + name: string, + handler: (args: unknown) => ToolResultValue | Promise, + ) => void; +}; + +type AgenticReactRuntime = { + showToolkit: () => void; + hideToolkit: () => void; + setToolkitConfig: (config: Partial) => ToolkitConfig; + enterSelectionMode: () => void; + exitSelectionMode: () => void; + setSelectionMode: (enabled: boolean) => void; + getLastSelectionContext: () => SelectionContext | null; + copyLastSelectionContext: (format?: 'text' | 'json') => Promise<{ + success: boolean; + copied: boolean; + format: 'text' | 'json'; + context?: SelectionContext; + error?: string; + }>; + registerTuningModalExtension: (extension: TuningModalExtension) => () => void; +}; + +declare global { + interface Window { + __AGENTIC_REACT_CONFIG__?: AgenticReactConfig; + __AGENTIC_REACT_BRIDGE_URL__?: string; + __AGENTIC_REACT_TOOLS__: AgenticReactTools; + __AGENTIC_REACT__: AgenticReactRuntime; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..3b3cc68 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,36 @@ +export { RuntimeBridgeServer } from './bridge/server.js'; +export { createStreamableHttpMcpHandler, initMcpServer } from './mcp/index.js'; +export { + buildSelectionContextForElement, + buildSelectionContextSummary, + buildSelectionSourcePreview, + clearSelectableElementCache, + createElementSelector, + createSelectionToolkit, + getSelectableElementAtPosition, + isSelectableElement, +} from './select.js'; +export type { + AgenticReactConfig, + CustomClientFunction, + CustomTool, + JsonValue, + SelectionContext, + SelectionResolvedSource, + SelectionSourceSnippet, + SelectionStackFrame, + ToolkitConfig, + ToolkitOffset, + ToolkitPosition, + ToolkitTuningModalConfig, + ToolkitTuningModalStyle, + ToolkitTuningModalStyleSlot, + ToolkitTuningModalStyleValue, + ToolResultValue, + TuningModalActions, + TuningModalContext, + TuningModalExtension, + TuningModalExtensionCleanup, + TuningModalSlotRenderArgs, + TuningModalWrapArgs, +} from './shared/types.js'; diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts new file mode 100644 index 0000000..50a863f --- /dev/null +++ b/packages/core/src/mcp/index.ts @@ -0,0 +1,1055 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import path from 'node:path'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + type CallToolResult, + ListToolsRequestSchema, + isInitializeRequest, +} from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; +import { getVersionString } from '../shared/node_util.js'; +import type { BridgeRequestEvent } from '../shared/protocol.js'; +import { + buildSelectionContextSummary, + buildSelectionSourcePreview, +} from '../shared/selection_context_format.js'; +import { + classifySourcePath, + isAllowedProjectSourcePath, + normalizeSourceRoot, +} from '../shared/source_path.js'; +import type { + CustomTool, + SelectionContext, + SelectionResolvedSource, + SelectionSourceSnippet, + ToolResultValue, +} from '../shared/types.js'; +import { + CopyLastSelectionContextSchema, + GetComponentStatesSchema, + GetComponentTreeSchema, + GetHtmlElementsSchema, + GetLastSelectionContextSchema, + GetReactSourceCodeSchema, + GetUnnecessaryRerendersSchema, + HighlightComponentSchema, + SetSelectionModeSchema, +} from './schema.js'; + +const builtInTools = [ + { + name: 'highlight-component', + description: 'Highlight React component based on the component name.', + inputSchema: zodToJsonSchema(HighlightComponentSchema), + }, + { + name: 'get-component-tree', + description: + 'Get the React component tree of the current page in ASCII format.', + inputSchema: zodToJsonSchema(GetComponentTreeSchema), + }, + { + name: 'get-component-states', + description: + 'Get the React component props, states, and contexts in JSON structure format.', + inputSchema: zodToJsonSchema(GetComponentStatesSchema), + }, + { + name: 'get-unnecessary-rerenders', + description: 'Get the wasted re-rendered components of the current page', + inputSchema: zodToJsonSchema(GetUnnecessaryRerendersSchema), + }, + { + name: 'set-selection-mode', + description: + 'Enable or disable browser element selection mode for context capture.', + inputSchema: zodToJsonSchema(SetSelectionModeSchema), + }, + { + name: 'get-last-selection-context', + description: + 'Get the latest captured selection context, optionally enriched with source snippets from the project filesystem.', + inputSchema: zodToJsonSchema(GetLastSelectionContextSchema), + }, + { + name: 'copy-last-selection-context', + description: + 'Copy the latest captured selection context from the browser runtime into clipboard.', + inputSchema: zodToJsonSchema(CopyLastSelectionContextSchema), + }, + { + name: 'get-html-elements', + description: + 'Find matching HTML elements by search strings and return deterministic candidates.', + inputSchema: zodToJsonSchema(GetHtmlElementsSchema), + }, + { + name: 'get-react-source-code', + description: + 'Find matching elements using search strings and return deterministic React source context for the best candidate.', + inputSchema: zodToJsonSchema(GetReactSourceCodeSchema), + }, +] as const; + +type BuiltInToolName = (typeof builtInTools)[number]['name']; + +const builtInToolNames = new Set(builtInTools.map((tool) => tool.name)); + +function isBuiltInToolName(name: string): name is BuiltInToolName { + return builtInToolNames.has(name as BuiltInToolName); +} + +function formatToolResult(result: ToolResultValue): string { + if (typeof result === 'string') { + return result; + } + if (result === undefined) { + return 'Custom tool executed successfully.'; + } + try { + return JSON.stringify(result); + } catch (_error) { + return String(result); + } +} + +const toTextResponse = (data: unknown): CallToolResult => { + return { + content: [{ type: 'text', text: JSON.stringify(data) }], + }; +}; + +type NodeMcpRequest = IncomingMessage & { body?: unknown }; + +type CreateMcpServer = () => Server; + +type StreamableHttpMcpHandler = ( + req: NodeMcpRequest, + res: ServerResponse, +) => Promise; + +const getMcpSessionId = (req: IncomingMessage): string | null => { + const header = req.headers['mcp-session-id']; + if (Array.isArray(header)) { + return header[0] || null; + } + return header || null; +}; + +const readJsonRequestBody = async (req: NodeMcpRequest): Promise => { + if (req.body !== undefined) { + return req.body; + } + + let rawBody = ''; + for await (const chunk of req) { + rawBody += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + } + + if (!rawBody.trim()) { + return undefined; + } + + return JSON.parse(rawBody); +}; + +const writeJsonRpcError = ( + res: ServerResponse, + statusCode: number, + code: number, + message: string, +) => { + if (res.headersSent || res.writableEnded) { + return; + } + res.statusCode = statusCode; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code, message }, + id: null, + }), + ); +}; + +export function createStreamableHttpMcpHandler( + createMcpServer: CreateMcpServer, +): StreamableHttpMcpHandler { + const transports = new Map(); + + return async (req, res) => { + const method = req.method || 'GET'; + if (method !== 'GET' && method !== 'POST' && method !== 'DELETE') { + writeJsonRpcError(res, 405, -32000, 'Method not allowed.'); + return; + } + + let parsedBody: unknown; + if (method === 'POST') { + try { + parsedBody = await readJsonRequestBody(req); + } catch (_error) { + writeJsonRpcError(res, 400, -32700, 'Parse error.'); + return; + } + } + + const sessionId = getMcpSessionId(req); + let transport = sessionId === null ? undefined : transports.get(sessionId); + + try { + if (!transport && sessionId) { + writeJsonRpcError( + res, + 404, + -32000, + 'Session not found for MCP Streamable HTTP transport.', + ); + return; + } + + if (!transport) { + if (method !== 'POST' || !isInitializeRequest(parsedBody)) { + writeJsonRpcError( + res, + 400, + -32000, + 'Bad Request: No valid MCP session ID provided.', + ); + return; + } + + const server = createMcpServer(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (initializedSessionId) => { + transports.set(initializedSessionId, transport); + }, + }); + + transport.onclose = () => { + const initializedSessionId = transport?.sessionId; + if (initializedSessionId) { + transports.delete(initializedSessionId); + } + }; + + await server.connect(transport); + } + + await transport.handleRequest(req, res, parsedBody); + } catch (_error) { + writeJsonRpcError(res, 500, -32603, 'Internal server error.'); + } + }; +} + +const resolveAbsoluteSourcePath = ( + rootDir: string, + filePath: string, +): string | null => { + const normalizedRootDir = fs.realpathSync(rootDir); + const normalizedSourceRoot = normalizeSourceRoot( + normalizedRootDir.replace(/\\/g, '/'), + ); + + if (!isAllowedProjectSourcePath(filePath, normalizedSourceRoot)) { + return null; + } + + const classifiedPath = classifySourcePath(filePath, normalizedSourceRoot); + const candidates: string[] = []; + + if (classifiedPath.kind === 'vite-fs') { + const fileSystemPath = classifiedPath.normalizedPath.slice('/@fs'.length); + candidates.push(path.resolve(fileSystemPath)); + } else if (classifiedPath.kind === 'fs-absolute') { + candidates.push(path.resolve(classifiedPath.normalizedPath)); + } else if (classifiedPath.kind === 'vite-root-relative') { + candidates.push(path.resolve(rootDir, `.${classifiedPath.normalizedPath}`)); + } else if (classifiedPath.kind === 'project-relative') { + candidates.push(path.resolve(rootDir, classifiedPath.normalizedPath)); + + const nxWebpackSourcePathMatch = classifiedPath.normalizedPath.match( + /^([^/]+)\/(?:\.\/)?src\/(.+)$/, + ); + if (nxWebpackSourcePathMatch) { + const [, projectName, sourcePath] = nxWebpackSourcePathMatch; + candidates.push( + path.resolve(rootDir, 'apps', projectName, 'src', sourcePath), + ); + } + } + + for (const candidate of candidates) { + const resolvedCandidate = path.resolve(candidate); + if (!fs.existsSync(resolvedCandidate)) { + continue; + } + + const stat = fs.statSync(resolvedCandidate); + if (!stat.isFile()) { + continue; + } + + const realCandidate = fs.realpathSync(resolvedCandidate); + const relativeToRoot = path.relative(normalizedRootDir, realCandidate); + const isInsideRoot = + relativeToRoot === '' || + (!relativeToRoot.startsWith('..') && !path.isAbsolute(relativeToRoot)); + if (!isInsideRoot) { + continue; + } + + const pathSegments = relativeToRoot.split(path.sep); + if ( + pathSegments.includes('node_modules') || + pathSegments.includes('.vite') + ) { + continue; + } + + return realCandidate; + } + + return null; +}; + +const getSourceSnippet = ( + filePath: string, + lineNumber: number | null, + contextLines: number, +): SelectionSourceSnippet | null => { + try { + const rawFileContent = fs.readFileSync(filePath, 'utf8'); + const fileLines = rawFileContent.split(/\r?\n/); + const referenceLine = Math.max( + 1, + Math.min(fileLines.length, lineNumber || 1), + ); + const startLine = Math.max(1, referenceLine - contextLines); + const endLine = Math.min(fileLines.length, referenceLine + contextLines); + + const snippet = fileLines + .slice(startLine - 1, endLine) + .map((lineContent, index) => { + const absoluteLine = startLine + index; + return `${absoluteLine}: ${lineContent}`; + }) + .join('\n'); + + return { + filePath, + startLine, + endLine, + snippet, + }; + } catch (_error) { + return null; + } +}; + +const LOCAL_COMPONENT_NAME_PATTERN = /^[A-Z][A-Za-z0-9_$]*$/; +const IGNORED_LOCAL_USAGE_COMPONENTS = new Set([ + 'Button', + 'Card', + 'Card2', + 'CardBody', + 'CardBody2', + 'ChakraComponent2', + 'HStack', + 'VStack', + 'Box', + 'Flex', + 'Stack', + 'Text', + 'Heading', + 'RenderedRoute', +]); + +const isExternalPath = (filePath: string | null): boolean => + Boolean( + filePath && + (filePath.includes('/node_modules/') || + filePath.includes('node_modules/') || + filePath.includes('/.vite/') || + filePath.includes('/@vite/')), + ); + +const inferLocalUsageComponentName = ( + selectionContext: SelectionContext, +): string | null => { + for (const stackFrame of selectionContext.stackFrames) { + const componentName = stackFrame.functionName; + if (!componentName || !LOCAL_COMPONENT_NAME_PATTERN.test(componentName)) { + continue; + } + if (IGNORED_LOCAL_USAGE_COMPONENTS.has(componentName)) { + continue; + } + if (isExternalPath(stackFrame.fileName)) { + continue; + } + return componentName; + } + + return null; +}; + +const walkProjectSourceFiles = ( + directory: string, + visit: (filePath: string) => boolean, +): boolean => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch (_error) { + return false; + } + + for (const entry of entries) { + if ( + entry.name === 'node_modules' || + entry.name === '.git' || + entry.name === 'dist' || + entry.name === 'tmp' + ) { + continue; + } + + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (walkProjectSourceFiles(entryPath, visit)) { + return true; + } + continue; + } + + if (!/\.(?:jsx?|tsx?)$/i.test(entry.name)) { + continue; + } + + if (visit(entryPath)) { + return true; + } + } + + return false; +}; + +const findComponentSourceInProject = ( + rootDir: string, + componentName: string, +): SelectionResolvedSource | null => { + const declarationPattern = new RegExp( + `(?:function\\s+${componentName}\\b|const\\s+${componentName}\\s*=|class\\s+${componentName}\\b)`, + ); + let matchedSource: SelectionResolvedSource | null = null; + const normalizedRootDir = fs.realpathSync(rootDir); + + walkProjectSourceFiles(rootDir, (filePath) => { + let sourceText: string; + try { + sourceText = fs.readFileSync(filePath, 'utf8'); + } catch (_error) { + return false; + } + + if (!declarationPattern.test(sourceText)) { + return false; + } + + const lines = sourceText.split(/\r?\n/); + const matchIndex = lines.findIndex((line) => declarationPattern.test(line)); + const realFilePath = fs.realpathSync(filePath); + const relativeFilePath = path + .relative(normalizedRootDir, realFilePath) + .replace(/\\/g, '/'); + + matchedSource = { + filePath: relativeFilePath, + lineNumber: matchIndex >= 0 ? matchIndex + 1 : 1, + columnNumber: null, + componentName, + }; + return true; + }); + + return matchedSource; +}; + +const stripCssEscapes = (value: string): string => + value.replace(/\\([^\r\n])/g, '$1'); + +const pushSourceHint = (hints: string[], hint: string | null | undefined) => { + if (!hint || hint.length < 3) { + return; + } + if (hints.includes(hint)) { + return; + } + hints.push(hint); +}; + +const extractSelectionSourceHints = ( + selectionContext: SelectionContext, +): string[] => { + const hints: string[] = []; + const selector = selectionContext.selector ?? ''; + const idMatch = selector.match(/#((?:\\.|[^\s>+~.#:[\]])+)/); + if (idMatch?.[1]) { + pushSourceHint(hints, stripCssEscapes(idMatch[1])); + } + + const classHints = Array.from( + selector.matchAll(/\.((?:\\.|[^\s>+~.#:[\]])+)/g), + (match) => stripCssEscapes(match[1]), + ); + for (const classHint of classHints.reverse()) { + pushSourceHint(hints, classHint); + } + + for (const attrMatch of selector.matchAll( + /\[[^\]=\s]+=(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g, + )) { + pushSourceHint( + hints, + stripCssEscapes(attrMatch[1] ?? attrMatch[2] ?? attrMatch[3]), + ); + } + + const domPreview = selectionContext.domPreview; + for (const attrName of [ + 'id', + 'data-testid', + 'data-test-id', + 'data-test', + 'data-cy', + 'data-qa', + 'aria-label', + 'class', + 'name', + 'title', + ]) { + const attrPattern = new RegExp(`${attrName}=["']([^"']+)["']`, 'i'); + const attrMatch = domPreview.match(attrPattern); + pushSourceHint(hints, attrMatch?.[1]); + } + + return hints; +}; + +const refineLocalUsageSourceLine = ( + rootDir: string, + selectionContext: SelectionContext, + localUsageSource: SelectionResolvedSource, +): SelectionResolvedSource => { + const hints = extractSelectionSourceHints(selectionContext); + if (hints.length === 0) { + return localUsageSource; + } + + const absoluteSourcePath = resolveAbsoluteSourcePath( + rootDir, + localUsageSource.filePath, + ); + if (!absoluteSourcePath) { + return localUsageSource; + } + + let sourceText: string; + try { + sourceText = fs.readFileSync(absoluteSourcePath, 'utf8'); + } catch (_error) { + return localUsageSource; + } + + const sourceLines = sourceText.split(/\r?\n/); + const searchStartIndex = Math.max(0, (localUsageSource.lineNumber ?? 1) - 1); + const searchRanges = [ + sourceLines.slice(searchStartIndex).map((line, index) => ({ + line, + lineIndex: searchStartIndex + index, + })), + sourceLines.slice(0, searchStartIndex).map((line, lineIndex) => ({ + line, + lineIndex, + })), + ]; + + for (const hint of hints) { + for (const searchRange of searchRanges) { + const matchedLine = searchRange.find(({ line }) => line.includes(hint)); + if (matchedLine) { + return { + ...localUsageSource, + lineNumber: matchedLine.lineIndex + 1, + }; + } + } + } + + return localUsageSource; +}; + +const placeResolvedSourceFirst = ( + resolvedSources: SelectionResolvedSource[], + preferredSource: SelectionResolvedSource, +): SelectionResolvedSource[] => [ + preferredSource, + ...resolvedSources.filter( + (source) => + source.filePath !== preferredSource.filePath || + source.componentName !== preferredSource.componentName, + ), +]; + +const findLocalSelectionSource = ( + rootDir: string, + selectionContext: SelectionContext, +): SelectionResolvedSource | null => { + const directProjectSource = selectionContext.resolvedSources.find( + (source) => source.filePath && !isExternalPath(source.filePath), + ); + if (directProjectSource) { + return directProjectSource; + } + + const externalUsageSource = selectionContext.externalComponent?.usedBy; + if (externalUsageSource) { + return externalUsageSource; + } + + const componentName = + selectionContext.componentName && + LOCAL_COMPONENT_NAME_PATTERN.test(selectionContext.componentName) && + !IGNORED_LOCAL_USAGE_COMPONENTS.has(selectionContext.componentName) + ? selectionContext.componentName + : inferLocalUsageComponentName(selectionContext); + if (!componentName) { + return null; + } + + return findComponentSourceInProject(rootDir, componentName); +}; + +const enrichSelectionContextSourceLocations = ( + rootDir: string, + selectionContext: SelectionContext, +): SelectionContext => { + const localUsageSource = findLocalSelectionSource(rootDir, selectionContext); + if (!localUsageSource) { + return selectionContext; + } + + const refinedLocalUsageSource = refineLocalUsageSourceLine( + rootDir, + selectionContext, + localUsageSource, + ); + const nextResolvedSources = placeResolvedSourceFirst( + selectionContext.resolvedSources, + refinedLocalUsageSource, + ); + + const nextSelectionContext = { + ...selectionContext, + externalComponent: selectionContext.externalComponent + ? { + ...selectionContext.externalComponent, + usedBy: refinedLocalUsageSource, + } + : null, + resolvedSources: nextResolvedSources, + }; + + return { + ...nextSelectionContext, + sourcePreview: buildSelectionSourcePreview(nextSelectionContext, { + sourceRoot: rootDir, + }), + }; +}; + +const enrichSelectionContextWithSnippets = ( + rootDir: string, + selectionContext: SelectionContext, + contextLines: number, + maxFiles: number, +): SelectionContext => { + const enrichedSelectionContext = enrichSelectionContextSourceLocations( + rootDir, + selectionContext, + ); + const sourceSnippets: SelectionSourceSnippet[] = []; + const seenFilePaths = new Set(); + + for (const resolvedSource of enrichedSelectionContext.resolvedSources) { + const sourceFilePath = resolvedSource.filePath; + if (!sourceFilePath || seenFilePaths.has(sourceFilePath)) { + continue; + } + + if (sourceSnippets.length >= maxFiles) { + break; + } + + const absoluteSourcePath = resolveAbsoluteSourcePath( + rootDir, + sourceFilePath, + ); + if (!absoluteSourcePath) { + continue; + } + + const sourceSnippet = getSourceSnippet( + absoluteSourcePath, + resolvedSource.lineNumber, + contextLines, + ); + + if (!sourceSnippet) { + continue; + } + + sourceSnippets.push({ + ...sourceSnippet, + filePath: sourceFilePath, + }); + seenFilePaths.add(sourceFilePath); + } + + return { + ...enrichedSelectionContext, + sourceSnippets, + sourcePreview: buildSelectionSourcePreview( + { + ...enrichedSelectionContext, + sourceSnippets, + }, + { + sourceRoot: rootDir, + }, + ), + }; +}; + +const parseSelectionContextResponse = (response: { + context: SelectionContext | null; +}): SelectionContext | null => { + if (!response || !response.context) { + return null; + } + + return { + ...response.context, + externalComponent: response.context.externalComponent ?? null, + sourcePreview: response.context.sourcePreview ?? null, + sourceSnippets: Array.isArray(response.context.sourceSnippets) + ? response.context.sourceSnippets + : [], + }; +}; + +export interface McpRuntimeBridge { + request: ( + event: BridgeRequestEvent, + payload: unknown, + options?: { + acceptResponse?: (value: unknown) => boolean; + broadcast?: boolean; + timeoutMs?: number; + }, + ) => Promise; +} + +const requestRuntime = async ( + bridge: McpRuntimeBridge, + event: BridgeRequestEvent, + args: unknown, + options?: { + acceptResponse?: (value: unknown) => boolean; + broadcast?: boolean; + timeoutMs?: number; + }, +): Promise => { + const response = await bridge.request(event, args, options); + return response as T; +}; + +export function initMcpServer( + bridge: McpRuntimeBridge, + rootDir: string, + customTools: CustomTool[] = [], +): Server { + const server = new Server( + { + name: 'agentic-react', + version: getVersionString(), + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + const dynamicTools = customTools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: zodToJsonSchema(tool.schema), + })); + + return { + tools: [...builtInTools, ...dynamicTools], + }; + }); + + server.setRequestHandler( + CallToolRequestSchema, + async (request): Promise => { + try { + if (isBuiltInToolName(request.params.name)) { + switch (request.params.name) { + case 'highlight-component': { + const args = HighlightComponentSchema.parse( + request.params.arguments, + ); + const response = await requestRuntime( + bridge, + 'highlight-component', + args, + ); + return { + content: [{ type: 'text', text: response }], + }; + } + + case 'get-component-tree': { + const args = GetComponentTreeSchema.parse( + request.params.arguments, + ); + const response = await requestRuntime( + bridge, + 'get-component-tree', + args, + ); + return { + content: [{ type: 'text', text: response }], + }; + } + + case 'get-component-states': { + const args = GetComponentStatesSchema.parse( + request.params.arguments, + ); + const response = await requestRuntime( + bridge, + 'get-component-states', + args, + ); + return { + content: [{ type: 'text', text: response }], + }; + } + + case 'get-unnecessary-rerenders': { + const args = GetUnnecessaryRerendersSchema.parse( + request.params.arguments, + ); + + const response = await requestRuntime( + bridge, + 'get-unnecessary-rerenders', + args, + ); + return { + content: [{ type: 'text', text: response }], + }; + } + + case 'set-selection-mode': { + const args = SetSelectionModeSchema.parse( + request.params.arguments, + ); + const response = await requestRuntime<{ + success: boolean; + enabled: boolean; + }>(bridge, 'set-selection-mode', args); + + return toTextResponse(response); + } + + case 'get-last-selection-context': { + const args = GetLastSelectionContextSchema.parse( + request.params.arguments, + ); + + const browserResponse = await requestRuntime<{ + context: SelectionContext | null; + }>(bridge, 'get-last-selection-context', args, { + acceptResponse: (response) => + Boolean( + response && + typeof response === 'object' && + 'context' in response && + response.context, + ), + }); + + const selectionContext = + parseSelectionContextResponse(browserResponse); + if (!selectionContext) { + return toTextResponse({ + success: false, + message: 'No selection context has been captured yet.', + context: null, + }); + } + + const enrichedSelectionContext = args.includeSourceSnippets + ? enrichSelectionContextWithSnippets( + rootDir, + selectionContext, + args.contextLines, + args.maxFiles, + ) + : enrichSelectionContextSourceLocations( + rootDir, + selectionContext, + ); + + return toTextResponse({ + success: true, + summary: buildSelectionContextSummary( + enrichedSelectionContext, + { + sourceRoot: rootDir, + }, + ), + context: enrichedSelectionContext, + }); + } + + case 'copy-last-selection-context': { + const args = CopyLastSelectionContextSchema.parse( + request.params.arguments, + ); + + const response = await requestRuntime<{ + success: boolean; + copied: boolean; + format: 'text' | 'json'; + context?: SelectionContext; + error?: string; + }>(bridge, 'copy-last-selection-context', args, { + acceptResponse: (response) => + Boolean( + response && + typeof response === 'object' && + 'context' in response && + response.context, + ), + }); + + return toTextResponse(response); + } + + case 'get-html-elements': { + const args = GetHtmlElementsSchema.parse( + request.params.arguments, + ); + const response = await requestRuntime( + bridge, + 'get-html-elements', + args, + ); + return toTextResponse(response); + } + + case 'get-react-source-code': { + const args = GetReactSourceCodeSchema.parse( + request.params.arguments, + ); + const response = (await requestRuntime<{ + success: boolean; + reason?: string; + chosenMatch?: { + selector: string | null; + query: string; + }; + context?: SelectionContext | null; + }>(bridge, 'get-react-source-code', args)) || { + success: false, + }; + + if (!response.success || !response.context) { + return toTextResponse(response); + } + + const enrichedContext = args.includeSourceSnippets + ? enrichSelectionContextWithSnippets( + rootDir, + response.context, + args.contextLines, + args.maxFiles, + ) + : enrichSelectionContextSourceLocations( + rootDir, + response.context, + ); + + return toTextResponse({ + success: true, + chosenMatch: response.chosenMatch, + summary: buildSelectionContextSummary(enrichedContext, { + sourceRoot: rootDir, + }), + context: enrichedContext, + }); + } + + default: + throw new Error(`Unknown tool: ${request.params.name}`); + } + } + + const customTool = customTools.find( + (tool) => tool.name === request.params.name, + ); + if (customTool) { + const parsedArgs = customTool.schema.parse(request.params.arguments); + const result = await requestRuntime( + bridge, + 'custom-tool', + { + args: parsedArgs, + name: customTool.name, + }, + { broadcast: true }, + ); + return { + content: [{ type: 'text', text: formatToolResult(result) }], + }; + } + + throw new Error(`Unknown tool: ${request.params.name}`); + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error(`Invalid input: ${JSON.stringify(error.errors)}`); + } + throw error; + } + }, + ); + + return server; +} diff --git a/packages/core/src/mcp/schema.ts b/packages/core/src/mcp/schema.ts new file mode 100644 index 0000000..71dd1d6 --- /dev/null +++ b/packages/core/src/mcp/schema.ts @@ -0,0 +1,117 @@ +import { z } from 'zod'; + +export const HighlightComponentSchema = z.object({ + componentName: z.string().describe('The name of the component to highlight'), +}); + +export const GetComponentTreeSchema = z.object({ + allComponents: z + .boolean() + .default(false) + .describe( + 'Whether to get tree for all components, instead of just your self-defined ones', + ), +}); + +export const GetComponentStatesSchema = z.object({ + componentName: z + .string() + .describe('The name of the component to get the states of'), +}); + +export const GetUnnecessaryRerendersSchema = z.object({ + timeframe: z + .number() + .optional() + .describe( + 'The timeframe to query wasted re-renders, in unit of seconds, e.g. 10 means last 10 seconds', + ), + allComponents: z + .boolean() + .default(false) + .describe( + 'Whether to query wasted re-renders for all components. Must be explicitly stated to set this to true', + ), +}); + +export const SetSelectionModeSchema = z.object({ + enabled: z + .boolean() + .describe('Whether to enable element selection mode in the browser'), +}); + +export const GetLastSelectionContextSchema = z.object({ + includeSourceSnippets: z + .boolean() + .default(true) + .describe( + 'Whether source snippets should be included in the response payload', + ), + contextLines: z + .number() + .int() + .min(0) + .max(80) + .default(10) + .describe('How many lines to include before and after the resolved line'), + maxFiles: z + .number() + .int() + .min(1) + .max(40) + .default(8) + .describe('Maximum number of source files to include in source snippets'), +}); + +export const CopyLastSelectionContextSchema = z.object({ + format: z + .enum(['text', 'json']) + .default('text') + .describe('Clipboard payload format'), +}); + +export const GetHtmlElementsSchema = z.object({ + queries: z + .array(z.string().min(1)) + .min(1) + .describe('List of search strings used to find matching DOM elements'), + maxMatches: z + .number() + .int() + .min(1) + .max(40) + .default(10) + .describe('Maximum number of matching elements to return'), +}); + +export const GetReactSourceCodeSchema = z.object({ + queries: z + .array(z.string().min(1)) + .min(1) + .describe('Ordered list of search strings to resolve a target element'), + maxMatches: z + .number() + .int() + .min(1) + .max(20) + .default(5) + .describe('Maximum number of candidate elements to resolve'), + includeSourceSnippets: z + .boolean() + .default(true) + .describe('Whether source snippets should be included in the response'), + contextLines: z + .number() + .int() + .min(0) + .max(80) + .default(8) + .describe('How many lines to include before and after resolved lines'), + maxFiles: z + .number() + .int() + .min(1) + .max(20) + .default(6) + .describe('Maximum number of source files to include in snippets'), +}); diff --git a/packages/core/src/overlay.js b/packages/core/src/overlay.js new file mode 100644 index 0000000..8d76354 --- /dev/null +++ b/packages/core/src/overlay.js @@ -0,0 +1,436 @@ +import * as bippy from 'bippy'; +import { highlightComponent } from './core/tools/component_highlighter.js'; +import { getComponentStates } from './core/tools/component_state_viewer.js'; +import { getComponentTree } from './core/tools/component_viewer.js'; +import { buildSelectionContextForElement } from './core/tools/selection_context.js'; +import { createElementSelector } from './core/tools/selection_selector.js'; +import { createSelectionToolkit } from './core/tools/selection_toolkit.js'; +import { + collectUnnecessaryRender, + queryWastedRender, +} from './core/tools/track_wasted_render.js'; +import { + __AGENTIC_REACT_BRIDGE_URL__, + __AGENTIC_REACT_CONFIG__, + __AGENTIC_REACT_TOOLS__, + __AGENTIC_REACT__, + target, +} from './shared/const.js'; +import { BRIDGE_GLOBAL_CONFIG_KEY, BRIDGE_WS_PATH } from './shared/protocol.js'; +import { fiberRoots, store } from './shared/store.js'; + +const customToolHandlers = new Map(); + +const registerCustomTool = (name, handler) => { + customToolHandlers.set(name, handler); +}; + +const agenticReactConfig = target[__AGENTIC_REACT_CONFIG__] || {}; +const selectionToolkit = createSelectionToolkit({ + initialConfig: agenticReactConfig.toolkit, +}); + +const runtimeApi = { + showToolkit: () => selectionToolkit.showToolkit(), + hideToolkit: () => selectionToolkit.hideToolkit(), + setToolkitConfig: (config) => selectionToolkit.setToolkitConfig(config), + enterSelectionMode: () => selectionToolkit.enterSelectionMode(), + exitSelectionMode: () => selectionToolkit.exitSelectionMode(), + setSelectionMode: (enabled) => selectionToolkit.setSelectionMode(enabled), + getLastSelectionContext: () => selectionToolkit.getLastSelectionContext(), + copyLastSelectionContext: (format = 'text') => + selectionToolkit.copyLastSelectionContext(format), + registerTuningModalExtension: (extension) => + selectionToolkit.registerTuningModalExtension(extension), +}; + +const builtInTools = { + highlightComponent, + getComponentTree, + getComponentStates, + getUnnecessaryRenderedComponents: queryWastedRender, + registerCustomTool, +}; + +if (!Object.hasOwn(target, __AGENTIC_REACT_TOOLS__)) { + Object.defineProperty(target, __AGENTIC_REACT_TOOLS__, { + value: builtInTools, + writable: false, + configurable: true, + }); +} else { + Object.assign(target[__AGENTIC_REACT_TOOLS__], builtInTools); +} +if (!Object.hasOwn(target, __AGENTIC_REACT__)) { + Object.defineProperty(target, __AGENTIC_REACT__, { + value: runtimeApi, + writable: false, + configurable: true, + }); +} else { + Object.assign(target[__AGENTIC_REACT__], runtimeApi); +} + +bippy.instrument({ + name: 'agentic-react', + onCommitFiberRoot: (renderId, root) => { + if (fiberRoots.has(renderId)) { + fiberRoots.get(renderId).add(root); + } else { + fiberRoots.set(renderId, new Set([root])); + } + + bippy.traverseRenderedFibers(root.current || root, (fiber, phase) => { + if (phase === 'update') { + collectUnnecessaryRender(fiber); + } + }); + + store.currentCommitFrameId += 1; + }, +}); + +const normalizeQuery = (query) => query.trim().toLowerCase(); + +const getElementClassName = (element) => { + if (typeof element.className === 'string') { + return element.className; + } + return element.getAttribute?.('class') || ''; +}; + +const getElementSearchText = (element) => { + const parts = []; + parts.push(element.tagName?.toLowerCase() || ''); + parts.push(element.id || ''); + parts.push(getElementClassName(element)); + parts.push(element.textContent || ''); + + for (const attrName of ['name', 'aria-label', 'placeholder', 'title']) { + const attrValue = element.getAttribute?.(attrName); + if (attrValue) { + parts.push(attrValue); + } + } + + return parts.join(' ').toLowerCase(); +}; + +const getElementDepth = (element) => { + let depth = 0; + let current = element; + while (current?.parentElement) { + depth += 1; + current = current.parentElement; + } + return depth; +}; + +const getElementMatchScore = (element, query) => { + const id = normalizeQuery(element.id || ''); + if (id === query) return 0; + + for (const attrName of ['name', 'aria-label', 'placeholder', 'title']) { + const attrValue = normalizeQuery(element.getAttribute?.(attrName) || ''); + if (attrValue === query) return 5; + } + + if (id.includes(query)) return 10; + + for (const attrName of ['name', 'aria-label', 'placeholder', 'title']) { + const attrValue = normalizeQuery(element.getAttribute?.(attrName) || ''); + if (attrValue.includes(query)) return 20; + } + + const className = normalizeQuery(getElementClassName(element)); + if (className.includes(query)) return 30; + + const text = normalizeQuery(element.textContent || ''); + if (text.includes(query)) return 80; + + return 100; +}; + +const findHtmlElements = (queries, maxMatches = 10) => { + const normalizedQueries = (queries || []) + .map((query) => normalizeQuery(String(query || ''))) + .filter(Boolean); + + if (normalizedQueries.length === 0) { + return []; + } + + const matches = []; + const seenElements = new Set(); + + for (const query of normalizedQueries) { + const allElements = document.querySelectorAll('*'); + for (const element of allElements) { + if (seenElements.has(element)) { + continue; + } + + const searchText = getElementSearchText(element); + if (!searchText.includes(query)) { + continue; + } + + seenElements.add(element); + matches.push({ + query, + element, + score: getElementMatchScore(element, query), + depth: getElementDepth(element), + selector: createElementSelector(element), + domPreview: ( + element.outerHTML || `<${element.tagName.toLowerCase()}>` + ).slice(0, 800), + }); + } + } + + return matches + .sort((left, right) => { + if (left.score !== right.score) { + return left.score - right.score; + } + return right.depth - left.depth; + }) + .slice(0, maxMatches); +}; + +const handleBridgeRequest = async (event, payload) => { + switch (event) { + case 'highlight-component': { + if (typeof payload?.componentName !== 'string') { + throw new Error('Invalid args: missing componentName'); + } + let response = 'Action failed'; + try { + const components = target.__AGENTIC_REACT_TOOLS__.highlightComponent( + payload.componentName, + ); + if (components.length > 0) { + response = `Found and highlighted ${components.length} components`; + } + } catch (error) { + response = `Error: ${error?.message || 'Unknown error'}`; + } + return response; + } + + case 'get-component-tree': { + return JSON.stringify( + await target.__AGENTIC_REACT_TOOLS__.getComponentTree(payload || {}), + ); + } + + case 'get-component-states': { + if (typeof payload?.componentName !== 'string') { + throw new Error('Invalid args: missing componentName'); + } + return JSON.stringify( + target.__AGENTIC_REACT_TOOLS__.getComponentStates( + payload.componentName, + ), + ); + } + + case 'get-unnecessary-rerenders': { + const wastedRenders = + await target.__AGENTIC_REACT_TOOLS__.getUnnecessaryRenderedComponents( + payload?.timeframe, + { + allComponents: Boolean(payload?.allComponents), + debugMode: Boolean(payload?.debugMode), + }, + ); + return JSON.stringify(wastedRenders); + } + + case 'set-selection-mode': { + if (typeof payload?.enabled !== 'boolean') { + throw new Error('Invalid args: missing enabled'); + } + target.__AGENTIC_REACT__.setSelectionMode(payload.enabled); + return { success: true, enabled: payload.enabled }; + } + + case 'get-last-selection-context': { + return { + context: target.__AGENTIC_REACT__.getLastSelectionContext(), + }; + } + + case 'copy-last-selection-context': { + const format = payload?.format === 'json' ? 'json' : 'text'; + return await target.__AGENTIC_REACT__.copyLastSelectionContext(format); + } + + case 'get-html-elements': { + const maxMatches = + typeof payload?.maxMatches === 'number' ? payload.maxMatches : 10; + const matches = findHtmlElements(payload?.queries || [], maxMatches).map( + (match) => ({ + query: match.query, + selector: match.selector, + domPreview: match.domPreview, + }), + ); + + return { + success: true, + count: matches.length, + matches, + }; + } + + case 'get-react-source-code': { + const maxMatches = + typeof payload?.maxMatches === 'number' ? payload.maxMatches : 5; + const matches = findHtmlElements(payload?.queries || [], maxMatches); + + if (matches.length === 0) { + return { + success: false, + reason: 'No element matched the provided search strings', + matches: [], + context: null, + }; + } + + const chosenMatch = matches[0]; + const context = await buildSelectionContextForElement( + chosenMatch.element, + ); + + return { + success: true, + matches: matches.map((match) => ({ + query: match.query, + selector: match.selector, + domPreview: match.domPreview, + })), + chosenMatch: { + query: chosenMatch.query, + selector: chosenMatch.selector, + }, + context, + }; + } + + case 'custom-tool': { + if (typeof payload?.name !== 'string') { + throw new Error('Invalid args: missing custom tool name'); + } + + const handler = customToolHandlers.get(payload.name); + if (typeof handler !== 'function') { + throw new Error(`Custom tool "${payload.name}" is not registered`); + } + + return await handler(payload.args); + } + + default: + throw new Error(`Unsupported bridge event: ${event}`); + } +}; + +const getBridgeUrl = () => { + const configuredUrl = + target[__AGENTIC_REACT_BRIDGE_URL__] || target[BRIDGE_GLOBAL_CONFIG_KEY]; + if (typeof configuredUrl === 'string' && configuredUrl.length > 0) { + return configuredUrl; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}${BRIDGE_WS_PATH}`; +}; + +const connectBridge = () => { + let socket; + let shouldReconnect = true; + + const closeBridgeForUnload = () => { + shouldReconnect = false; + try { + socket?.close(); + } catch (_error) { + // noop + } + }; + + window.addEventListener('pagehide', closeBridgeForUnload); + window.addEventListener('beforeunload', closeBridgeForUnload); + + const establishConnection = () => { + if (!shouldReconnect) { + return; + } + + try { + socket = new WebSocket(getBridgeUrl()); + } catch (_error) { + if (shouldReconnect) { + setTimeout(establishConnection, 1000); + } + return; + } + + socket.addEventListener('message', async (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch (_error) { + return; + } + + if (message?.type !== 'bridge:request') { + return; + } + + try { + const payload = await handleBridgeRequest( + message.event, + message.payload, + ); + socket.send( + JSON.stringify({ + type: 'bridge:response', + id: message.id, + ok: true, + payload, + }), + ); + } catch (error) { + socket.send( + JSON.stringify({ + type: 'bridge:response', + id: message.id, + ok: false, + error: error?.message || 'Bridge request failed', + }), + ); + } + }); + + socket.addEventListener('close', () => { + if (shouldReconnect) { + setTimeout(establishConnection, 1000); + } + }); + + socket.addEventListener('error', () => { + try { + socket.close(); + } catch (_error) { + // noop + } + }); + }; + + establishConnection(); +}; + +connectBridge(); diff --git a/packages/core/src/select.ts b/packages/core/src/select.ts new file mode 100644 index 0000000..63b25c9 --- /dev/null +++ b/packages/core/src/select.ts @@ -0,0 +1,31 @@ +export { buildSelectionContextForElement } from './core/tools/selection_context.js'; +export { createElementSelector } from './core/tools/selection_selector.js'; +export { + clearSelectableElementCache, + getSelectableElementAtPosition, + isSelectableElement, +} from './core/tools/selection_dom.js'; +export { createSelectionToolkit } from './core/tools/selection_toolkit.js'; +export { + buildSelectionContextSummary, + buildSelectionSourcePreview, +} from './shared/selection_context_format.js'; +export type { + SelectionContext, + SelectionResolvedSource, + SelectionSourceSnippet, + SelectionStackFrame, + ToolkitConfig, + ToolkitOffset, + ToolkitPosition, + ToolkitTuningModalConfig, + ToolkitTuningModalStyle, + ToolkitTuningModalStyleSlot, + ToolkitTuningModalStyleValue, + TuningModalActions, + TuningModalContext, + TuningModalExtension, + TuningModalExtensionCleanup, + TuningModalSlotRenderArgs, + TuningModalWrapArgs, +} from './shared/types.js'; diff --git a/src/shared/const.ts b/packages/core/src/shared/const.ts similarity index 94% rename from src/shared/const.ts rename to packages/core/src/shared/const.ts index 2e6f367..d7fa498 100644 --- a/src/shared/const.ts +++ b/packages/core/src/shared/const.ts @@ -1,4 +1,7 @@ -export const __VITE_REACT_MCP_TOOLS__ = '__VITE_REACT_MCP_TOOLS__'; +export const __AGENTIC_REACT_TOOLS__ = '__AGENTIC_REACT_TOOLS__'; +export const __AGENTIC_REACT__ = '__AGENTIC_REACT__'; +export const __AGENTIC_REACT_CONFIG__ = '__AGENTIC_REACT_CONFIG__'; +export const __AGENTIC_REACT_BRIDGE_URL__ = '__AGENTIC_REACT_BRIDGE_URL__'; export const target = ( typeof window !== 'undefined' diff --git a/packages/core/src/shared/custom_tools_script.ts b/packages/core/src/shared/custom_tools_script.ts new file mode 100644 index 0000000..5e761e5 --- /dev/null +++ b/packages/core/src/shared/custom_tools_script.ts @@ -0,0 +1,108 @@ +import path from 'node:path'; +import type { CustomTool } from './types.js'; + +export type ClientImportSpecifierResolver = ( + filePathOrSpecifier: string, +) => string; + +export const toRelativeImportSpecifier = ( + fromDirectory: string, + filePath: string, +): string => { + const relativePath = path + .relative(fromDirectory, filePath) + .replace(/\\/g, '/'); + return relativePath.startsWith('.') ? relativePath : `./${relativePath}`; +}; + +export const toBundledClientImportSpecifier = ( + rootDir: string, + fromDirectory: string, + filePathOrSpecifier: string, +): string => { + const normalized = filePathOrSpecifier.trim().replace(/\\/g, '/'); + + if ( + normalized.startsWith('http://') || + normalized.startsWith('https://') || + /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(normalized) + ) { + return normalized; + } + + if (path.isAbsolute(normalized)) { + return toRelativeImportSpecifier(fromDirectory, normalized); + } + + if (normalized.startsWith('./') || normalized.startsWith('../')) { + return toRelativeImportSpecifier( + fromDirectory, + path.resolve(rootDir, normalized), + ); + } + + if (normalized.startsWith('/')) { + return toRelativeImportSpecifier( + fromDirectory, + path.resolve(rootDir, `.${normalized}`), + ); + } + + return normalized; +}; + +export function generateCustomToolsScript( + customTools: CustomTool[], + resolveClientImportSpecifier: ClientImportSpecifierResolver, +): string { + const toolRegistrations = customTools + .map((tool) => { + const safeToolName = JSON.stringify(tool.name); + if (typeof tool.clientFunction === 'function') { + const functionSource = tool.clientFunction.toString(); + if (functionSource.includes('[native code]')) { + return ` + console.error('[agentic-react] Unable to register custom tool', ${safeToolName}, ': native functions are not supported as clientFunction'); + `; + } + return ` + registerTool(${safeToolName}, (${functionSource})); + `; + } + + const importSpecifier = resolveClientImportSpecifier(tool.clientFunction); + const safeSpecifier = JSON.stringify(importSpecifier); + return ` + import(${safeSpecifier}) + .then((module) => { + const handler = module.default ?? module; + registerTool(${safeToolName}, handler); + }) + .catch((error) => { + console.error('[agentic-react] Failed to load custom tool', ${safeToolName}, ':', error); + }); + `; + }) + .join('\n'); + + return ` + const registerTool = (name, handler) => { + const tryRegister = (attempt = 0) => { + const registry = window.__AGENTIC_REACT_TOOLS__; + if (registry?.registerCustomTool) { + registry.registerCustomTool(name, handler); + return; + } + if (attempt >= 40) { + console.warn('[agentic-react] Custom tool registration timed out for', name); + return; + } + setTimeout(() => tryRegister(attempt + 1), 50); + }; + + tryRegister(); + }; + + ${toolRegistrations} + `; +} diff --git a/packages/core/src/shared/default_toolkit_icon.ts b/packages/core/src/shared/default_toolkit_icon.ts new file mode 100644 index 0000000..c539336 --- /dev/null +++ b/packages/core/src/shared/default_toolkit_icon.ts @@ -0,0 +1,2 @@ +export const DEFAULT_TOOLKIT_ICON_DATA_URL: string = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHQAAAB0CAYAAABUmhYnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAgAElEQVR42u19B3hUZfb+3O/eACISJjOTSQIhkMzcmQmgUgQUSEhPqCr2gh3FgthYu67rrn0t2Na67tq72MEudgEpunZZ3b9df66CIknm+7/nfPfO3JlMS7Eu8zz3SZl2732/c75z3tNcrk2PTY9Nj02PTY9Nj049NOsQLtdpOHbWXdXVRsaDXsOvjb130+MXBhBgAJidCZgeesTAdolNIP8cIKqbnRLAsrLqPgMDzYOKzLpt/MH6qT6zZm9fsPZwX6Bhgb+8/g++ITgCtQuKwnWH+oI1s73hSVP9wdqxweDUga7KnXul/kosFrVgxKbb3yOP05QUJt1Q/5YNm3srake5g1VzvMHaywqDTY8DuA8LAnXrvIGGNl+wUfIRapaFoSnSb06RhUH8brbg72bpM5vwfL30BerbvcHGdfj5vjdYt9hTUXWhN1C1f6FZu2Xx6NF9O54Lg7tJcjv/iO1v9kP4KsYHPIH6uZ5A7e3eipp/eyvqpJdAM22AFIjeAB317QC4Db9vBMAbfRX1G718NNDR6g3WE+jt/B6zUb0/9jkNBHQU0vwBpPwWgHugPzRuSCKQHc5v0yM9kPEbVxSpKfNV1BwBgJ70VNR9TwB6g01RT6BBAtwo/tfmAVAF5TVt7iHV7fllk6L9Bm0b3bxkbLRv8RjZp2iU7F04EsfWsaOPf6TczD9K9i3ZJtpv4Lho/8ETogOGVLW7h1bjs+qwAAjsBkhzE6S5JVoIoCG966ARlngCkw7xhLYrcS4065w3PdIBOXr06DxfoKYRqvROSNW3CsRGSAxABIAF5bWtBMAWpdtFNyseLXv5tpR6QaUU7rDUBpjSRUe+fQSTfk88ND5Mfp9eEJF5nhFYBCPlFoPGRweUVbVjsbRaKjzqw0KyVPXXnkD1De7yiRMci0/bBGxsX1Kqq7KysldhoG5Pb6D2FS+pvGATqc4oSYynoraNpK9v8TYyzzuCwVPAWcAMIFBC6iBg7WNAOP7/tIcZ/+kEG/8zPMNYykmKsZhaAWwbJDdaqFQzgK57qjBUM8NhqIn/VQPKqaqEL9Q4C/vWSgVig6VKa9sgiaQ+GUS6wS5LmrKDFMoRyEyfZ0t2gKWYwO1bMkYOGDKpHefYir23ndQxgYtFuLQgWF2fbuv4H5BKtYoLhkweB/CeIYMEBkzUS0CW17b1L90O+91WcTWa31MgpgLTzAiuSAA4gHMy+dxwjrzwSFJZYk0yxibf5zGrww5Qxe+cDFBSOXjERLc3ULfQU9GIPYpVKxkk7f1LJ/CeyNKYcHPDPQxoKA2QZobnSJ1bB/8vCM0xXNJeTgsRGoaBxe/rCiomn+gKNPf+HUtrfK/0VUxu8gQb3vVCKmmFE5j5gycqIC3jJXdAMklcdyU1w2fZwLJRFZC9sCXkY5/FtbTCeGtnaQ3Wvwzp3fp3CGrc6PEOrT3PA9UK65HBhKsQJaND3Ryzh9Vnpr3S7ORhqV93KIVKDscMM3KJCoZOJoucDCi4VnXrvUMmz0ukK3/77ojLH2oagj3yWQ/UK7kC8CHb4C/iBkW6uT+a3T/yc/l/budDwOq4pn4Dx0uyBbyshqGJArW3ustH5zvvyW/wwZSdy1M+uQ6r9WNPsJnBLBhaEyVnv2cMne4ClfR3vuNv5+85nYP9d5CNp97+rUkLsbR6g0SE1K30DKkJOe/Nb8j4USfsq2icDRX7o0fRca35ZROlDvO/+2BmASgTmJmeTwt+roCayiq2DCdydcg+8BJBARVcEGj4FITJpN+SpMYs2UKz4WjQdCSV7WQFkhpS1qrZs1KZnwHUDtIXTAGSmca6DeWoctMbUKSCBf63BbYXb2xfrf9uQMWkmb8RUNUJessbTlZWLNyRQF078ao960vmqGpTgZoSpK5YyJ07XyJIaE+F9Rv1BBt/hLTu/CtXvxaYFfUneq39kvYQsvwygunuhqrNploz7aXppD0ruOlBF0kulYhZx2HWDpthYVMkCD541Gs2bSwI1u7wK5VUywDi8Bb2inKm7qIU3bD9SrooIsATwVScK5n+6jnzp7dqu+TCZKYN9YJwWrDpOdvdoXuxWdFoqXxVbEfBhvUFEZsy/NWAqk7EF6ibhX2CIhTtJJlOMFkK+8P66zVYujYfgqMcx9D40RdH77Ic1Z358xz5uQIKMqRXKa6hLPF67N9xzXTtsX21fwCSug2R+4qAMJs+c5dXD/+VgKpIA39w8jiY5v+1CIP23og3OiXT1d+UfQuHyYsuvVauXLVGvrpshVy+/DX5Kg76+cory+Xsg47lG6H420wqrickLguQ+ZnUrxmzZknyCLS5R54kl+GaluFali1XP199dYV8beVqeeY5l8i8AWSMqYXNahj3pi/2VIrecFA+2LCm2Kz2OgiIXyxRy+Urqy6Cmn2L45bY9PsWjUmg7wwPyANjoNx1n8MkPR5/8hl53/0Py/sfWiwffOQxede9D8o333xbfv75F7KwfKx0bQHL0FOpbhxJNt6vWSQ9/yTVbPOp9LutxmPPWa8tCMefi702HGekUn6O/VxYHbHvDDteq/7WaaFuXiEHD5sgN/zwPRbq6/Lu+x6UDz68RD6A474HHpVPPvUsX3Pd9D1xDwbxvYjvrbB+S7clSW31MflQd/evA1Cz6W4vcnXIz6QTTJYWA+C4RIk84phT5Pfrv5MDh4yWkGkp+pRJvQ9UsGuA3H/O0XLjhg2y1ByHv31SiCKpAVitX4XU8LeWN0h9Xi/8xHu1vkOgxhDW0oulRq8lkCDdmqsQrylVIBglUtP86jPotVqhej0tNlLv9Ln4fv5c+j89vwVe168cv+Mz8wYqEHuX8ucK+nx6LX0fntdJOkWxjITHMmjN0/fia9E3GyJF78F8HRWhcfzcjnsczPfA4IUaSqAS88uq+N5xxMlsOOUXUr3qC6Fij/GqQDTFLi3pSNxvGFCtRB561Elyw/frZNmwydLVL4BIRSUI+eHSBbAOnneCbP1xgxwU2la69jhW9jr2r1IMGiO1wHZSP2GhFDsdLrXNsACm7i/1U66Q+uhpUniHSf3wv0gDh0DGgjaySeadfLnUp+zPoOq7zpf68ZdKYU6UomSk1I86T+oHnsKgiYk74XOulPrk3RlAfZ8FUj/mAqkNHid1vN7Ad+p7HgcQy6Vo2Vfqp/4N3zlVLdBD/yiNI/7CRpwrUiMj517PoE3f+UDp0gfxNeXh3Fx4b2hULT+3/a4HpQSUMyXwP3c5+N9gQxRarpWYNed29rPtm0WhljHgZpFlVw+rtiaqe4andE8SAP1hvRxcOQkXW8Gcpy29Bx3+BwXo8Frp+uMNstcNT0pRAWnfqkkat78ixbEXMqDGYbiRdy2Xeu0eAGm01C97QBpX3A9wcaPqZ0vjbjw393SWTv0kgHDrC1IfM0WKsnHS+OezUpx7sxTk7M+aJ/V7V0qx65EAdKjU/3Kj1G94CgtoAl4/Xeo3PyeN065iLSIO+qPU8bmiYW+oWlzPZYukceWDUvcBtJrZ0rznJQZt2k4HxEAjy9a12VAZ3HoyP7fDbnP4HqSSUAK1VyHRhLXtRBF6zcY17tH1+T+X+tVsN8UXaHzGY0lnH4cRlBzaigE6/yT5IwAdMpwkNIiQ0zAEiofz3jLniOPlRgA60ISEujxSJ/VHapJVrhfqr0RJf6+BSlUCXLYc6XV0YCEJUsN4b+y1epF6L6lQei29T/jVufUpVc+ROmWV64+pXDJy+DmjWL2WvxOf27dMvZZeBxXMeyhUbig4hkGbQVKYV8rXRNdGWig00pLQ3ecw2HkdJNQyGnHviHjgKE2ohVTv+T+T6rVUbbDxGEW217UhwJvRzTC8SgoPxx66HntoSelWDJqAOa9D3bpcW8j99j9S/rjhB1kamcA3glZ/zN2hm2AbN/ST/rZVOxkZnkjcgEowiqznbGPG4zB86DWeFK+1F2LyawsqE19bEJfCyDb1DFrLlD1wLZtLHepeAFiXyy0rAqOVhO5BElocM4pSkhI4TzeH3iiPCummkYaqn1r18gd7h9aZsGq/xJcqVVtQmRlQklCjVO627xF8cQ8/+oS84+775V233invxs+bb7tHvv7GW/Lzzz6XvqGwkCGVwh3uZm6QmYWnTeFv5ufiDjkIA5Is7JNllROwGDfIl15eJm+54155zz0PyDtxbbfdeZ9c8tiTfM31ZDDpA6VRkB5Q+n4K8hPvTcng2E+fxe02fkLVq1YK4prX+8iqxQa+WZKLknrlxf3QhVf8HeC9KVetXiNXbtwoV639t1wFX235itfghx6DFQ8LsSAHMG03gxZTQbjrZEQyiG7nZ4ezfpbyQ8vl3Pkny5WrX5crcR2vfbBWrtzwI7sxa954Q5557kIASWo1wz2y91PcS9J4CGy0Kau36cCfSPUqMIvNlgleyxmmjLycpchmiuAuuLDHuTaDqn3oLenabmeopgL8f5BiVLKBYqdnQtWxK0MHFoFSk5UprezMUhpK9HUBDrs98Ju13oPjiydTCgylzdB19SE121e6YJy57lyOv8GIYR9nBqy/mWXRx7MhKOTmqahhahDB8bcHj5jq/imkVPmcgYZ7STop0y2B2stIVMfjhGTqUzhJ94+QxmPvSQNug8CFC1yEnk3N0o0lwwfGib5VoxR1sHRrdsHv9bBcLZ+UALG3gPwslJ3b2lfJYIJVLEBJitAEKSbtJEX9HlIfNx3nGlTAJoBqpuRy6RoEFoIx8zBp3LvC4nAjvG+KHAIQIsFA2ga1OBAcqsuJNC3oYSm16L2KuslUhqCkc1K0q6EkQTcQRo/++PtSVO+mLM1Me4sNJrkQw2qkft1jUn/9Ryk+kdLAIej3RSulPg/+aHA7BSx9B70nP02YzF48eK0oQub9HsdIcSPcmhXfSeM/+MzPpNTfa4e7skxqE3ZUUutknVKdIy0OSKQ+/RCp37fSovjMNFojE7Amu3TkxhRyvU3je4Mqxxf0oJQqQFHncReZ1JSuSNKZGKU3kyQylD6IjJstfLASH39PCnLsO0hAqGNEhiQviGyHp9ZK8Slu9kNvSv2sW6Rx3u1Su2elNN6LSvE1gFj2lRRHny+FfzjUcamDznOcC6lm2qvJ/9zrWCke+0CKL/Def0upPfq+NC6+Vxp/uUmKO16V4mP8f9U3UoydoVR7pv3aAlRYgMa0hLuzi55sDiWlAJP30uJIw7weklLbEKobiQ16A1lfKAqK5m6EpHiOJbSSJVSv3j27hNJzoOH0826R+ucA8+xblAqj92Hf4t9HNktx+vXSWP2d1AGs/uibSiXrJSpLIua6VCrSAQSGfv3TUockivc2Sg0giqpdsNCGs1oX+GyxOTTCcRdLQd95w2PK980QOBBOCV20Mm50uUNdCJrHpdRPfmmwfiVlTfaAlFqhsWDTXwvDU7FJN7Ypy9bMMbKRGlCWUOyhojqLhNINJH516DgpXvxK6jjEwFFKWuC8xwh03Gzav7ThdVJc9rCSrHehio88VwqbhKDvAFlgzDpc6su+kNqXeM3NL4AGnKUWB51HvvIziVZkQ4ay+Ba/LcW/NkqBRcB8sTtNQnaSyk2U0K5lEPZD0VQhlT0SeR+qn9FdKeWVMKiysQAf+G+STloxiT5iNj8u9R5KVJ1YkgOgZODAkiVp0z+CSvzr3YpQT2ZcbGKAgMbzgnjYlf8nDahS/cI7IcX4TjA++pEXSH0t1PM7G6Q4AmATG2V/f9I58DnSc6ddLXXs1fqswxThn7CXOo4CC9AZFqCxXONUmfm5SSlxwlT9RkXLXrP+jm4SDVayV7hpd5bOUGN7v0HjkvjaXFJCUkgobpa+xFK5vTKoXLIQIVVih0Ol+AYStWChovQ8lanVfIHl1iAiQvueseRtqX8LMK4C93r6tayyxQufwIqdrYynDm6J45zxHfTd2twz+DPEfqeoKEzCdzuuuyBJQsmKjgW2zRy2o9T/o6q7QgpNmo1fo3J9qJPk6ZoxFJ5yR2FoKrsqed4tHZZjrumQSc64vYcuyWEPpZuHm2TsfKS6qUcjCkN8bOymprk5RNbTPjh0vNRvfE7qMHjo0Ba9LgUIf/YzU0Q+OgBKC2POqdJYj+8+4HQVjkv+7vwklWtL6IBkQM3OF0lZKSuFbByBNw/WHNRVtauqw9CEwhds/pKNIfCMMT4z+SbkdyKPhy6SJPSx9y2VW5reerQA1WceIg0Aqp14mZIsOwCeSWVZEibORiTlQwWouP5x9je1AZEU1mdHa5gl9LAz+buN/U9SsVBvZepF4NxD733NcV9COSz0DMYRth0YpVC78DDMhge7KKFWCYPZuCvp76JwC9TteDan0xbL5nSELUAjbBTpWfdQOOcIaovaPaWAf6hfbO2h7nBm9oYNINzcky5nl0R/YLUKw8EQ0hfei712cNzoSqMOBX0GGCz9zOuUNbz9XJxLaQrtkLiHCieg/YM5pI1mMY7wmv6Dt+PWAL5Q0xclwyaVdgFUy7oNNV3jD08jCqq1F2oitXyzC3k7STfbltAlDkDdkdQ3lgClPCMQCuINIhBWK6s3PwuYoBf1A0+TGgwp8dzHUkRqET8dJfVHQDcSGQFJF1n8XwJUgJUSNz4jjbV4z7iZli8aSS1pDkBFSgnNlVjoaHMwc2Q2tXMdaqhuVmfVrmb3/UEbmNf9MIgoWVq4I53Pj01nFBFTlHYPNTtEIajIVhARsXK91CrGqzipU0rdijZjPxJqUm/YS+Z9EAWjtF6KCbNU+sgWeE8lFsZLn0iDJA6ZEZpAbLNwhMphSvi8MO/1+sCRUjyDBbEM5MKg0WoxpeOLY4AeHN9DU1Gj7o7MUGoJjv+d5xtBvR3a/GG05wk3Xa7wzBlQKyMhWB8Bb9taBECJiO8YwDY7aRgl+aGPO/fQSHq1RFJKBs4FtytGBykhnFtE4Nk3FzFUDaEszv0JV0EqYcn+q03qLfvhf/kqoM1B7XypTwBX++o3Ulu1Turjd+BgtiDmiNgom6qDu8D/q9pJGh9Jlc2QvIg6nGeEA+VixsFSpDSKco0CpSLuw2TDtPnZ22h6KbFZR1Zta7krocY9iiLTeP+kaHoc0O4lNosEYsHict3h1D5bvmJ3BFSo2At86/8B0JOvBAgDVBIX7YX9AUQZ1On46TKvdndOH9E+kHxTjYORPnI82J6TLsV+ijDWqcg5mvsnqd28VBlKD2Fvnb6/NGp2g1qvxnlFFEmPzxWuflIceqbU8Z2Uj8Q+qGdY+oUXs3IJ0NfitTRZ98xsgKp9vv/giVEyTtFY5NuSis7so6r9GhUbnceAhlpaEyMrKfJXO1HR1QFQkraYcZJ0YQVWZgHAE4PQc2H5d1Jb/C4SwWaDJDhH6pcukvrDoPle+T8p1vwo9XegZt+XDJaGvVL7Siq68DP108ChkZQTsQ82SQfwvD++FZXaivVSX/ofqd/0DHzWq6S+M4yge16T4m28busGlZbitjMmUhiFMSv3YIeVG8wBtMzuHsdciTUaOE76Q83tBGpJpKlT2faaRcY/WBSZzv6nkVACmAbQFGpYWEcHct6bAlDn59i5sKRqydIMgZg/4CQpln4GVQqm5702qf0XQJCUvd4mjSc+lGLxB1J7C4Bhz9NPgHsz72yp7XeiFLsfJcUuRyIhbL5KCtsThPyhf5b6/LPBVr2Lz8J7HsYiWbRGGiu+B4sUZanU/h/Axndpy/G/U5FpOGlHK0xXomjBZMaoA/XnULkpF3xnCqWCqA7fWvoRUivCPlpU2XCUU/iyJ4GhmSEc2beLIlO5HDDRIAp1Yf90nCjRbRZTJKpsQJMSoilGSnHPyYh3XnyP1Fd/r1yO99ukeBNAPvFvKfY9keOWwpzEITBx+8tSBwhit6MhTf3VjSeflYPVpYqRogQy+pvUJ+X/NO+nCAdEb0TZNrCEt5Zi2xlS3xE0310rwAcD1NdBE34ZZakXd70q9dnHqWgOPkc4c41iKheSfe/KDBKahtzPlMlANCCECol5rcXApNisv7JTgA4K1g5E7PO7YmzClDfkyjd7oOGEmcDl6o/ae+ggR/Z7RAWbt2qQ+pUPQSW2S40sUpK6U6+WgnJun/sURP3nAFEFlCkjz5iPnFsAri1cpCxadCdh8r5whCIRmOMtU34wLEa++fjJfuUZ1zKtaOAnJ2oTF4x92YBqFyuxkJr2kWKfE6R2B4LWa9tkHpH6D/8LdOQhaqGQFNqJZGTlOpmiTqncND0c3MoSNohgQL4RGal+s+GRHI0iK5gdaRgHXd1OEjpgyMRox8wEs5OUVop46GPktuwW43K5Og2WqA7J01f9l4PM4oE34F4cJzVIjspgL0CY7GreGwUMHk7NBPhixbdQl99KLTJZZTWw86+y58UgJFkjSK1X7QwpHKNSOfNDylWhlM3Bo6XxwsdSe7NNBbNpr9ztSEVIQDuoLPpiVrd6DTIsLntIio/apSBtABZKeMLKJaIFFJPQ1+IB7qw8rtlRKlP8Ttke7vKadgK0KNS8IrdwmmXhlgxrmM5vDE9pR/VxNH2qSaq9M8veQIAid5WIBY0AtaInzAjNP1fdqDVwKeadp2g2Up3k/5GbQgCMauF9zkDMU9usVBoX3aXcmcPOgjU8SL2HfFfcZOPwP7N61uHCGO9A2p/+CN9xDpcvsmSRpJKU77VAahQ4v+EJLDCAd/sLzExxTJX2cZJqkkIyzmjRTZ8jNdgAHIL720NKigqsQAKMIuOBVYpp6m/mkN+UmlAQKSxd6hpDhipipB/nlmtk6WR/ZdO+ZBDBZWkj6yqjhHYwjrIEvPl1AWmAtTEm76UkCqyN2PFQNnLEi18gV2gPRYTnJyV/kaFEN/SqhznDQJyBcoY130Pa31VqloCk3B4KfFOpA1myb0GaXsexBvsvfmer90/Xs6oXdsHSFsheuOdVZSGfgYz5N/H6O15RgYQO2YaVam8uR4wWuUPkSukLLmRbwAXA86Bye923jDPytfwuJgGkULtkGOWDDyByAeHMH0pDM0pyBrQw3HyUH4AWR6a1og2pAtQdyrEEL8MFMCBIsEZNSK+XEbAmkoCKk/wA4UFEQuBG6FMOVAVJpMKSHXmSAlK9KEsw3iWwWqWA20E5QaxeIcVcBFW/NwwaGDKrNwJwHG+0qeN19Tc9x6UNpO4pqZskb9qBbPEKfKZOrsysIxxRmaSbT+eG89BHIEFtObaHZV8jwWwiExcGjLK8p5EqA8A5O9Ed6aGy/iA3kaQMBqIBy7eeGsweH7UlNNxygj8yA4BO3UjhGy0lKZ/cJSTzimNLmaQRe56xFEYOrFU2cJAJICiLjyTuhqXqNbEM+I60GVN8VGB03eNSJ7V4y4sq9mhHSCAp+hnXKOJg9Y9xMP9lHat+5GC5/qfr4gFrWmikzq9erFTtba84gAyn1E6U1cAq9izUy9CejgUitgTAtFCx2PKefF9q5gT1uVkSxURWfjdslx8qQOGLVoSnjs4Z0JJhU04iQLGPxgAVnW7XlkyNWYzPiZdIjcBbtp4dfP3YC9S+SG7J+Xcq14KAyUSa03579s1MHogrH1Z1Le6IJcF47tK7mTDQiWxgINvhU7ar35ElyK7KxXfxHsy8Lb2P1PSFd6m99O9PWEBksBkIUDKCjr9M6t/jPJpmQ81frrIbyHelc6PCKS1dQL4TmYDuOKDkhxKgpeUtYzoD6MlFANRvAaras6RbTTk6zJxOAtW2x1F80QLSo9GeuTv8unKkX0IdUtYdq0DOAUrRAscdYfDIrRGvfQvjSLKBJBr3VknXpArxUz/tb0pCIY0GGUROQFdtUBJ66pVKpXqtPRe8rU65Q0QsENnQuI/6zIJI6q2D9uv+sG6RVM1qH4FzcdBpKp8J18LG3Yw56jNyAjSDgRTLqncCWp87oIWRlhOLKuMS6rIkVKQxr3Pu9ZOv0j2N4y4BoDBSTr1WRVLI97viAUXVnXyFZRCZHW+ElbUn/ni1IhHAyZLzr9/yjKoOI0kjUn3b7WHYKOk01thqt1VJ7BtkHG3khGr2TZmIB6DXLea9kwwujRbKNY92yPTja7bOibIZ2P8liUaIjavccC36+bfzItJR+siEflYNlp54EB0kdLuuAeqLtCwoqpxJRtFG7jHEe2i4g4+UW8TATEhwZsux72CZB8vUmLCziodugZsxvF7qL33GlJt28mXs91GFGu29rBLJxaBCpvLx4G6/ktpLX3GKibhpKefqUpSDc3HZyEEIDfm5lIitk3GE/Vp/M6q4XrJ8KTept4rYkKsjEJXhAPo9K1QeEwLibBhtM025Vfb3c9BbEQ/6Hxbyvq8vR2gNNaUEqAsg5005AFbucrV1dCrR2kxbIhEn6CfYgEaHjGkK5QxoUWXTwUpCp7WqSEumhOFsWX9mhwA3UXV5lOBsh8/YasSNnQiAX/xM7ad3vAwpmsXqUCNgKdJBRgyqsDmpmowaRF1E8/4cXdEfAXtDzFB/FbMU1KuBrN/7X5c64qgGDvHwW1Lf+w8wqhS7Q8W/OogBg6IjpKKRjEaWqjjoFOVjnnG9soRpMRPwkGQiKIxbX1IM1stwsWr3sqxlLA7K+d3+MEX9eSJdTLROL6GUMEaAgqRvLRnWmEPExSIWUE+xiyIWWohYYEBFl1ucpsgpQiWaeAS+46Td4ikoHsswGYYk6BuXcoKz9u5GaVz1qNRBv2nuChXWun8NWB08t80M9Vlk2Cy8i/N+BIgEZoK8FhGANBSSLN5zt6xV+ba0b1rBcEGlEHNOkXnkS179EAyhMmVFl41FDvDnKhN/IPjdAqhSuDX6VUukAJ/MxtiNz+MzGyymqzIp629VPODg7jqQCUZRPhELVVFi7/yhxq+Ltm725UD/KUCLUGyKVSCLIlN4VXSlRiMt4M4UlMlJGQu0PxEXCykTiIroFCr7VEU+yE81Lrmb9zf9thfVvkcxUQpQV6AXA6lgIhlGTYWlq/Y+di3Iuqb9jUIUDDcAABYKSURBVPe4iBXTDLPU68PqkHQNP/LVb+EbT2ZeWPSxJJGsYEit8c+nIdnvslrXyOB58C2WfM7pJT/T3ufthDZQf8YiR15ujvctFVOUbLcUMPXHEvoODSPKhc9l8R0YqQpiKtEPRDNRth/VNgp3D7UJt4qVhJWC0iG3x84AJBeHohpIcBY3IEb51ga2JjVifeDMU/8E8efrpDjwZGlst70ylCjmeeNTAJHyjipUTlDvgQySylrAAug3RAWyfeBH0SuB3QuUWRgNe3KMVVxwK/bS12ANr4Nh1aYMpGWIw14O92jmwZw+w8H15JxeR7SFJTS2DYWzuHSpjMxEH5Qr97BgaLACac6ScPPSjm0SMpUNVlb3Q/PFT0i8adQGGybdVBsdJfT9pKy/pL2WVjztt+TqEDDjYKC88KXMW/0D0lfWqqwEClyTZUz73dutyt0ACPpi0Ir3r1b78E3PImi9lI0n7ZbnELRewdES/QmE715X72GG6msVCDfIwHobLs59SEh7DkbaKljENbuqBG9yQVJZ3za4sbzcdOR8Jyk/S93ST+pvj+IlhM8oi6ThtlwD3DG00RDjWRhF3O3K8AzvRgvx1OEzY0lSGmd+mqb9tNeRWq7CTSXLk0JkFOEYNYXb3Qi0wNGQb6Tf/DxUHfbXVevZQtUAlgvgMgP1gTqIbNDgvmjki75HTNIPIOLB2V6zGGE0SPuc0xVBAFXM/u6xFynif/YJKkhAAYJ0C9WuPpthp3GGUmT9daIGKKlTCmWNIJ+ojffQcOOfc42HxgwjT6jxGiLo/aEpnMIZa/WWoutkpkLflOEzktDF72fJKbI+j6SB1O8fLmEpor2V45a0J5LqgxHEDBT5s0QJUh4RuT6PoAwCqZdiBGjFkdaBrD8dRpd29wp2UwzqTUTAgRViA4msaDJyyH+kz67dTRlAlz+o8peS63pSZv0dolrmJBcr5XehsMtRuEScOgBtL2Tqr36P3FNQYgR94/xijrhMaeubkCSWa+QgQ/iMAX0vMXM+DbnPKgd+KksS8nu00VNUwNrujkI/bWefDiILzr+VVXEeygQprZOlDWALtKQxzrhB6l9YIG1uRUScn2MXPpFhNngMamHgmiz9hEN+HMbLUh+qdwA03OlZMR17VAStJDEm5tH+p3bLThQuWUnWZvVEIoKLYelSxbYrWyQla8GvKmYVVgoKGUWiwx5qpgATxs2QsVKDJSqe/Ejd9Pw0ozfocwj84q2l8eAa5mW1oy/gwDnzvyhnIGrOwP6pDxmvAM2UtU8L4ZYXmI0SWzc5Eq3Tl22whN6TawV35lEiyiAKqzROWLiFFDoLNL4XCIzt35laUX5Rf5SBF4ZbPimpnE61FVGR9cRSqVuzI2VoZSzEAS1Nf9FMmiMctt2O6qZevURRfBnOhVQ0p6GQan3+EwaQ28ShnEJ7G7zvqu+kGL+9WkgZOFb+HDo3kAvMy047KA236yh9JLXNmfPOgt9wl0kFuyMMGUTUrITzcsNNi7pQVmglW0emLlJqt7mtt6MUQnTSEEpZwb3Yzvor7cCXJtxUpuYOUJXUxNzklaTNtI/5bl4VCCBCQqckL1R2c8wSHKvYaV6a6rNUxU74LsoQJOKB8opE6siJSLBy51qJ1iHZXf/dHoNCUxgR0mzzU4JYeMrxna9As/ZRrxXojmUu9A+mX00ZfCmnyo3lFAFQvSpz0ww2iHSqup7HmQHa0ReqTp2eSMZFxOmj/q04+0/MQ2rK+5KD1/pZ/8T/3CqlJNsNJUBJhQJI8R3eP+dM1RYuzUJgvhnnyn5oD0hoQpJ12URkKtC04iaJAuyxXZZQd3nVCJRDbCR2omCo3VvBTAtoVh/UmfW3OIsfGrupquDXoJt63EVJN7WjW8BkNlF8qFvRq3fhfVcR9CAkXvkGpYkHqxrTdEF053dTfeg+x1v1oada3z0s9T0osM41OUmsC4A6hYOSw6hPPaWeIOl9TXV1dZ+u9FqIvRjpnM/6eY51UxvN+Mrmj4pM8zktYiFu5e6WWMGdn6JGk1Ru074qCxBdM9mdSGhZ48xer+TELMokMKj8b/lXiiig8vt9wChR/BWMk061nlT7Se5JOmm3AukaEtcMuEvGnsekVbn2HpooobgXMUCzV3CLVJouX43fQnSljTSlL9zcjaaOlj/qxvwVKikEf9i2BdWI5jxsLpSxx4IqJ9wts1HEaSvwEdEMQ1sNIO5/Q92kWG1nKLFpIyU/U/TkeGQOrAXbQ40z9j9Vcbu0b+4M4+iNdVKjNJNzkPFQvJWjNL8y3uWamztWcvaDgUIlVtnbzVLx02zFyTMUOZ/ZbcnSVSwhwkKJYeR7wuOobJ7YjcYZ1jTBYTUVMJf/y+1Vslq7oYzNCePUX0RRf5OzWLk2cOw+PKvyfRBv5Han9DnU9ZosWgISr6EyQoppMrvzPPKWYEzF2q16VBK3Ngn5uVSGQVTf4+8g2xBccX65StrmwEBQSTm1UB0/kzMADSpl9FZauVXh9FaxDWhCfWgnAY0dqhs4DRvwg+6Dx/Hyad0fhmeBGm65TcVHW9rI4ur6SEhn0wyHhCatepHMkZIPOfUgpv70Fz7DTZuDBGqEtSBhAh2y9V2OQE7tM6D52hXolz/EJRJcZmEnXdscLOXZlqJ58jm3gexXoTCD9rx9YcWOqOXEbAqZ6ahkMx59R/HF+5+icn4zFgjHVW5ionXmzHmRju6jFnEDx1IObhtryMopR/RA8ylL7VbWNZNTC5M5ihBON8JnpHJNVfC7+L2k1nDpc2pUvctgxa0SYMhczwODoz+BarE1G5RE0v/vgaqbOZepvIS+f87zsDIfuLExJVKjqQZHcYjkp5yipz+WxrNYNLQ4KL3k3FsVGZLl+pKtXOF2UH8ZBuOJdPsotEoBZt+Q74kObp+VhupLerKJo+YNT1lKZD0sLYzxGJkgpSKjb5rYjlSnRhAANG+Jat5IgwT0DFl+CfsKqV44+Po1j7P1Kp7/XN28P/0DSV2z1U1kSQrHy/xTlSO4rTAdST4SogXUMNXO6LejHdwzn0idqrb/8SxXeRNTxRnwmcgMui7SOlDpBhp85C1axc0bdUdwOpcIS7yBI9rRIvWHeHRSt0WVzRf3YAPHGFm/ZyEBivKI3KU0aZgqtSJFUJgn0T/yjnRN2JU7PVMXaxu0zG1aVZtyJtMRK6VUFg3ZBLxPxkJbkRR7dxqrmzVDWMVkifNl/xWfi26hnOdkT6PI0KTD7nNIPjp8Aumagu7V6ItPI0DoWp0GjpZD/m2CdKoc3O/84YbhPdnd2hbxXmj3+XKRBSpP5nWkpoh0Lkt+vFFwX1igReWjsOKqZMmTa2XR9P2kv6RS+gOYxgBQXf1zGJVl97Zl4yUYBzHl/pYpvTSU2PTYTqy2Pzdjr9w430qTkgwQ//4AJKq4UpbMPlEW378SM2hGyuLAWNmHmh+T+s+h559wjNMir4LVbbjlyp+gCbI1qA65RhZzROOwMpAJTjWrBtbQgIDnX3iJhwX88P16uSEald9jgM0G+h0/L7jkWgY0J0l1zE3LaCHndyLXydkQOWOBUShhmA5lJ17791swIGGd/GE9jo0/yh/a23GN62Rb60a5eMlTsqAUQxZovEgOHoJQTRuVdIZb/ls0rD7yk/ae94aalygpbW7b3K57ySAZ9iiPAw87jvuvn3bmBfKwo0+W8489Vc5f8EdMhDgBE4kexoCBdQB9PE9W0rkhhqWqiJkpqIzzpQlDBByhM7dqQmxLFVudsfJ50zGMwIz5mbFk7tjnmikGF4RTficPEcC4ksDIGr6u6/55pzwI0y3mLzhNzj/udDn3qJPln846X8102YXmtgxMMbelY+ewzan0PtzMvC1qjM79CadDqBUyABNqfSY2axPNkM2GKJXra/lmWrqPR2VhzAeB+P26b+XAwaPV9KQ8mgpBbb37y/0PPIqnQgwiQHnMh08REDzmo0DVkLitEgjyQbnDZoB9Ra4RpZwnnqxUoKxXtxVsptdubrW0od95zEdIWczE6VLqKO1bFCSnulCKd5J6pN+JxnOraU6CPpcSuMn9oXPDdxpEnGP/j1hjPlqm7sXXEp8KUSDLK+ypEAenGcTj3Kpwr1RUpZ2YOVi2a5EIVvgTz29RoHrMpoV+q6EGjSSmlSXStHyxAaVBPKSGhgyvZtXay0dzW0bwrBOerARVNSiMMR97LpC9FqBzSSnK9YIYjHPSZdwjgW4o+Z/G6ShEohROSLF+xFnSmHeOyu4b1SwNKiGkMBdVhiEzTz8BjFGoiluyCoqLHnS6WijojcsV4VTAS4Fv9GLQ0RtXK0PitlmFyUqoV9n7eGuyEsr20ZFT4DsJYINaxVHDDtI8CNGFz/2Hmtuyiz23ZQRfG20z4TENjrktA9MDalVpDxhazXsnNcv0R5oP+Dlmt/BK6YdJeqCi3vajXRmVtm1WrKZDOEvKhXOYHVZyfLJSFV8sXRyPlML4izk0WQmAllqTlfJoslI5Jitt2QhXAknNNFmJ3Ju5aDND/dzr0SqueKTKL7p8kWJxkPCs3w234+DTFHN00pVS0GQljLiiyUri70jHPAedqmkv2/FwjlmKneexRIoz/4nnn+TJSoLGcN30HNe+cIkG5RnhO7WG2ayO9cvuk/rf7MlKe2Oy0ovxUVk0bAfXxNeMxWBa6niHTIC67WmF2wDMJivm2bLoZxxsp1bMgHDdNJ9Vq4hEsmieXXCbtPry6OJEfPbZoAgA7T1ERRLIYHIVywPmLnBMVgLlRgQ8D7MLqKlHVLYwQHVFYVK9r1K5VPLAdB7t46Ra+bUWlUiuDGXq2ZOV6H12eQLlB9Fzds8/bqZRrFQzvZ5+J9VNn9vHcmno87nMv1h1K6Nzx8IJWcPspu4EQF1F/H9Sza5eZTK4pRqVNXO3OWlVLn0HNcTAmCzuQ4R7+lVJqCbUjTaq3fBNzeaLC0PTyFfCeOZatc8ltzVjQIvlETCCSEKHYjwjtw1F9nxf+Hw04uOQecdDQjeoyUqQXt3jKPZ1zlApSD36MWadxqxeM8kCdrTKSWUhuyOO8ZKm+tvtMKDckcTzsaYVk59ZOUZNVpq5y4E8wqQvipfp2lxYGJGRVfzcrD0P6QCos8wBuc8kGKxqveHGg36JCYVKFfi33Bz5uy/RJo79tC2/bGIHR56mCdFMlqbtZ/PFffLJJ/LDDz+SH330H/75/vsfyB/gtvwLM0S3KBmhKt3c4U7kLOU69T7b67NPU+qQvAUN4ivfRn766Wfy2/9+Iz/AEJ6PPvoI1/WhXLt2LU+Lam9rk2Mn78ASa4+sZmYp3iEMocmWjXQP0b3tHz/zZMKOWfbuipphoAU/x0lxp6vNS8aldGVoxe4y+zC58Mrr5YULr8a036vlhZdeIy+67Bp53kVXypHbTVPMStZJSWZuBcZZsxPNDH0Lc+tCzaDCut62dpb8q3UtF/HPa/naLrniOuytBzCxEOv15I4PWVfkezO3IIdNsnzw4InuX3gorK1662aiFSs3vEf35WhykbBNa7nYnPc7jqLYofoRdL30Lvfn0xPkoiuf51ZD7dR1FCsq02UfftZOwqHelfYJcp4Qt0zlaYTUZLp+xC8pnc4EJM4/KjAbjuLe9JiJhhON2iMo2eG30vlJ/SorsDJ25LG1C0c9ReaA6Caw3UpmyzFFxO4hRNdA16aOypgVb9gTEq20TLon1OatkAQA9wmAbvSGm6b+SoaqJ00uDDWdrcaBNCeCmjWJLPebmLvq7TqYoluvcza6sBdzOKZmKXvSx5pMSWdJpHl/p2D8Wh5aHNTGyzggGyRQG0E0j0rDJMUnCWmWBHcZlHwzC/g9L6WZABXOji1uNQNNsyST3BMfWbSUpxWuP/zXJplpQG1aWMiFTk0YFt4Y7Vsc53xt8kE49hRbJdt7jci6+jNJa9eBE7mCleOCscNhGufWjmFaD/eknf33UO2Rv2YwHaCqTb0g1HAWTdejQTKkYqgS3BmB6QioOuxoh8h0w92ZW6l1rot0V9R9rt2/1EE5zeRn+pj/Jn+zec5vAUwHqOpE8wP1872hFppoGKUhp+4hVTJO5oeTMtzCHSRAdEKCumYAmd3cf1NvIbZUUloKKuABZksrW7PBpm89ZuP2v8Y9MzuoVhrogEDDdFzIlzSmgpo9IIuNjSXVejQe/xS5NI/oZs+8ntgfc5Jky/ih1rS87RADZDa+6wlVj/ktgtnBpQGZH8Yg9mU8MgQXSMNO+2M0MTMnPWQF/5KAOoEUrGLH8qhILGKm82iAjn/LbQtzL9L9NT/szipo8lAQrL/UxxGa5ihl4lNknqXVahbsNJC0jI2ufm4gzYxZevb501B099AaGprTyjFNs+lHb6ju5NNiZMFvY8/M0U+1YqmB2umQ1rX+0FQ1lhg97Qdgn+nlGxHvVe/umPH3U6nJZGMrV4NHWHs+We9kF0DjkK0Aq7653UcWfqBhZWG4blsHkJrrd/aIGUv9zNFe7ClX+NhXnaIsQICbXzYhmmcDm292GtCYX/tTSa/bKZGqbTgmNkZRTNQWi5aYjd9j1ucZxaOn9f09g5nE/0q+QDdWsCfU9LRfdc+OUoCXRkLT/trLt1VsyHhyXaroYQnNdYK9fR4UvyQgqc0MA0nTG6FiPWb9/f5IzbA4J/uL87I/v79Ke6wv1LATjpU8gS/YzPUcxAnDzYkS00SSQGkrLssydvqqogfBFB18XCWJNidNnUjQb6/dkkhM3sX5UlDabHjBN6y+KSloobn+9x6OVRwI9PaEavbAvOlXfTwdnlVxlKxipIyS1EaptI7GL9o32ZbeVI33k63mGJGR0R2KA0g/yQon94NGJ6PHQZsiSZqiZK0TSeAJ1i31mJNn4uz1/0WpzBaG0+zf3aHJTZ5Azd2eYMN6Ne9LgUv7LAbrtVG7Okp37A21bJdRxAFWh/o7qbNKvhOwYNJ71CDzXhh627dkDAiBiVQhwEZbYbA5aqtVLLhvvObkm7yRmqrEGWS/Hwu2p42meLExWtQVBOtO8ATrl3nJgmRg6bBzmVh6WzFsr53avhDFRjUhJMkENlnOed7Eg1wLOnqDKKfX4j1Rms8J9d7mKa9rpc+E29Gugs5T+fvw/RuwwJ4vCFTPd5dPGBw/ZbkJyE5IbFx1YUaJNzxplC9YcwL2q6cQsfiG9i++4TyCkUBuZjWo5mzCeiaGJti4kV0J+zDpf00b6SeXGiDDws8MVotU2Xbq4BKEYOMX3mDdo16z7mh3ZMKwxIStTaq1G/5rBwND8w4eXewO1kxBpsSZICvugRpcDVW8zoumTCy9HJpC3jCVbcQO7v+LYzo/p6SPxiA3QwIbvwOAqzxm7Z3eYO1J/lBtTb/A1r7EU5Ha/7Cx87OB67JntUGCi4vMum18ocmzfGb93OJQ04lFocZzisyGSzDN75ISlOQhQHAJSjjOLQw1nOwza+YWBqt2KIbkFw6t9TsmFaX4zk3S+POAy7xoj95s52duksRfOLhu+bYW0LkccenTNgG46bHpsemx6bHp0cnH/wdtSkyyDr7dfQAAAABJRU5ErkJggg=='; diff --git a/src/shared/errors.ts b/packages/core/src/shared/errors.ts similarity index 100% rename from src/shared/errors.ts rename to packages/core/src/shared/errors.ts diff --git a/packages/core/src/shared/node_util.ts b/packages/core/src/shared/node_util.ts new file mode 100644 index 0000000..7974859 --- /dev/null +++ b/packages/core/src/shared/node_util.ts @@ -0,0 +1,27 @@ +// This files must be run in node runtime + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let packageVersion: string | undefined; + +/** + * Read package.json and get the version string + * @param packageVersion + * @returns + */ +export function getVersionString() { + try { + packageVersion ??= JSON.parse( + readFileSync(resolve(__dirname, '..', '..', 'package.json')).toString(), + ).version; + } catch (error) { + console.error('Error reading package.json', error); + packageVersion = 'unknown'; + } + + return packageVersion; +} diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts new file mode 100644 index 0000000..392c0da --- /dev/null +++ b/packages/core/src/shared/protocol.ts @@ -0,0 +1,31 @@ +export type BridgeRequestEvent = + | 'highlight-component' + | 'get-component-tree' + | 'get-component-states' + | 'get-unnecessary-rerenders' + | 'set-selection-mode' + | 'get-last-selection-context' + | 'copy-last-selection-context' + | 'get-html-elements' + | 'get-react-source-code' + | 'custom-tool'; + +export type BridgeMessage = + | { + type: 'bridge:request'; + id: string; + event: BridgeRequestEvent; + payload: unknown; + } + | { + type: 'bridge:response'; + id: string; + ok: boolean; + payload?: unknown; + error?: string; + }; + +export const BRIDGE_WS_PATH = '/__agentic_react_bridge'; +export const SOURCE_LOOKUP_PATH = '/__agentic_react_source'; + +export const BRIDGE_GLOBAL_CONFIG_KEY = '__AGENTIC_REACT_BRIDGE_URL__'; diff --git a/packages/core/src/shared/selection_context_format.ts b/packages/core/src/shared/selection_context_format.ts new file mode 100644 index 0000000..a253949 --- /dev/null +++ b/packages/core/src/shared/selection_context_format.ts @@ -0,0 +1,250 @@ +import { toAbsoluteSourcePath } from './source_path.js'; +import type { + SelectionContext, + SelectionResolvedSource, + SelectionSourceSnippet, + SelectionSourceTraceFrame, +} from './types.js'; + +export const WEB_CONTEXT_TAG = 'web_context'; +export const WEB_CONTEXT_TYPE_REACT_COMPONENT_LOCATION = + 'react_component_location'; + +interface SelectionContextFormatOptions { + sourceRoot?: string; +} + +const getPrimarySource = ( + selectionContext: SelectionContext, +): SelectionResolvedSource | SelectionSourceTraceFrame | null => + selectionContext.resolvedSources[0] ?? + selectionContext.externalComponent?.usedBy ?? + selectionContext.sourceTrace.find((frame) => frame.kind === 'project') ?? + null; + +export const buildWebContextText = ( + selectionContext: SelectionContext, + options: SelectionContextFormatOptions = {}, +): string => { + const lines: string[] = []; + const componentName = + selectionContext.componentName || + selectionContext.externalComponent?.componentName; + const primarySource = getPrimarySource(selectionContext); + const source = primarySource + ? formatSourceLocation(primarySource, options) + : null; + const details = buildSelectionContextSummary(selectionContext, options); + + if (componentName) { + lines.push(`component: ${componentName}`); + } + + if (selectionContext.selector) { + lines.push(`selector: ${selectionContext.selector}`); + } + + if (source) { + lines.push(`source: ${source}`); + } + + if (details) { + lines.push('', 'details:', details); + } + + return `<${WEB_CONTEXT_TAG} type="${WEB_CONTEXT_TYPE_REACT_COMPONENT_LOCATION}">\n${lines.join('\n')}\n`; +}; + +const formatDisplayFilePath = ( + filePath: string, + options: SelectionContextFormatOptions = {}, +): string => + toAbsoluteSourcePath(filePath, options.sourceRoot) ?? + (filePath.startsWith('/src/') ? filePath.slice(1) : filePath); + +const formatSourceLocation = ( + source: Pick< + SelectionResolvedSource, + 'filePath' | 'lineNumber' | 'columnNumber' + >, + options: SelectionContextFormatOptions = {}, +): string => { + const filePath = formatDisplayFilePath(source.filePath, options); + const line = source.lineNumber != null ? `:${source.lineNumber}` : ''; + const column = source.columnNumber != null ? `:${source.columnNumber}` : ''; + return `${filePath}${line}${column}`; +}; + +const formatComponentSourceLine = ( + source: SelectionResolvedSource, + options: SelectionContextFormatOptions = {}, +): string => { + const location = formatSourceLocation(source, options); + return source.componentName + ? ` in ${source.componentName} (at ${location})` + : ` in ${location}`; +}; + +const formatExternalComponentLine = ( + selectionContext: SelectionContext, + options: SelectionContextFormatOptions = {}, +): string | null => { + const externalComponent = selectionContext.externalComponent; + if (!externalComponent) { + return null; + } + + const packageName = externalComponent.packageName + ? ` from ${externalComponent.packageName}` + : ''; + const usedBy = externalComponent.usedBy; + const usedByText = usedBy + ? ` used by ${usedBy.componentName ?? 'local component'} at ${formatSourceLocation(usedBy, options)}` + : ''; + + return `selected external component: <${externalComponent.componentName}>${packageName}${usedByText}`; +}; + +const formatSourceSnippet = ( + sourceSnippet: SelectionSourceSnippet, + options: SelectionContextFormatOptions = {}, +): string => + [ + `Source (${formatDisplayFilePath(sourceSnippet.filePath, options)}:${sourceSnippet.startLine}-${sourceSnippet.endLine})`, + sourceSnippet.snippet, + ].join('\n'); + +const formatTraceFrameLine = ( + traceFrame: SelectionSourceTraceFrame, + index: number, + options: SelectionContextFormatOptions = {}, +): string => { + const arrow = index === 0 ? ' ' : ' -> '; + const location = formatSourceLocation(traceFrame, options); + const componentName = traceFrame.componentName + ? `<${traceFrame.componentName}>` + : ''; + + if (traceFrame.kind === 'external') { + const packageName = traceFrame.packageName + ? ` from ${traceFrame.packageName}` + : ''; + const sourceLocation = traceFrame.packageName ? '' : ` at ${location}`; + return `${arrow}${componentName}${packageName}${sourceLocation}`; + } + + return traceFrame.componentName + ? `${arrow}${traceFrame.componentName} at ${location}` + : `${arrow}${location}`; +}; + +const buildSourceTracePreview = ( + sourceTrace: SelectionSourceTraceFrame[], + options: SelectionContextFormatOptions = {}, +): string => { + if (sourceTrace.length === 0) return ''; + + return [ + 'source trace:', + ...sourceTrace.map((traceFrame, index) => + formatTraceFrameLine(traceFrame, index, options), + ), + ].join('\n'); +}; + +export const buildComponentSourceChain = ( + resolvedSources: SelectionResolvedSource[], + options: SelectionContextFormatOptions = {}, +): string => { + const lines: string[] = []; + const seenSourceKeys = new Set(); + + for (const source of resolvedSources) { + if (!source.filePath) { + continue; + } + + const sourceKey = `${source.filePath}:${source.componentName ?? ''}`; + if (seenSourceKeys.has(sourceKey)) { + continue; + } + + seenSourceKeys.add(sourceKey); + lines.push(formatComponentSourceLine(source, options)); + } + + return lines.join('\n'); +}; + +export const buildSelectionSourcePreview = ( + selectionContext: SelectionContext, + options: SelectionContextFormatOptions = {}, +): string | null => { + const snippetText = selectionContext.sourceSnippets + .map((sourceSnippet) => formatSourceSnippet(sourceSnippet, options)) + .join('\n\n'); + const sourceChain = buildComponentSourceChain( + selectionContext.resolvedSources, + options, + ); + const sourceTrace = buildSourceTracePreview( + selectionContext.sourceTrace ?? [], + options, + ); + const externalComponentLine = formatExternalComponentLine( + selectionContext, + options, + ); + const shouldUseSourceTrace = + Boolean(externalComponentLine) || + Boolean( + selectionContext.sourceTrace?.some( + (traceFrame) => traceFrame.kind === 'project', + ), + ); + const preview = [ + snippetText, + externalComponentLine, + shouldUseSourceTrace && sourceTrace ? sourceTrace : sourceChain, + ] + .filter(Boolean) + .join('\n'); + + return preview || null; +}; + +export const buildSelectionContextSummary = ( + selectionContext: SelectionContext, + options: SelectionContextFormatOptions = {}, +): string => { + const summaryLines: string[] = []; + const sourcePreview = + options.sourceRoot || !selectionContext.sourcePreview + ? buildSelectionSourcePreview(selectionContext, options) + : selectionContext.sourcePreview; + + if (sourcePreview) { + summaryLines.push(sourcePreview); + } else { + summaryLines.push(selectionContext.domPreview); + } + + if (selectionContext.componentName) { + summaryLines.push(`component: ${selectionContext.componentName}`); + } + + if (selectionContext.tuningPrompts?.length) { + summaryLines.push( + [ + 'tuning prompts:', + ...selectionContext.tuningPrompts.map((prompt) => `- ${prompt}`), + ].join('\n'), + ); + } + + if (selectionContext.selector) { + summaryLines.push(`selector: ${selectionContext.selector}`); + } + + return summaryLines.join('\n'); +}; diff --git a/packages/core/src/shared/source_path.ts b/packages/core/src/shared/source_path.ts new file mode 100644 index 0000000..6074f69 --- /dev/null +++ b/packages/core/src/shared/source_path.ts @@ -0,0 +1,243 @@ +const SOURCE_FILE_EXTENSION_PATTERN = /\.(jsx?|tsx?)$/i; +const PROTOCOL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//; +const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[a-zA-Z]:\//; + +export type SourcePathKind = + | 'invalid' + | 'url' + | 'vite-fs' + | 'fs-absolute' + | 'vite-root-relative' + | 'project-relative'; + +export interface SourcePathClassification { + kind: SourcePathKind; + normalizedPath: string; + isSourceFile: boolean; + withinSourceRoot: boolean; +} + +export const normalizeSourceRoot = (sourceRoot?: string): string => + (sourceRoot || '').trim().replace(/\\/g, '/').replace(/\/$/, ''); + +const normalizeSourcePath = (filePath: string): string => + filePath + .trim() + .replace(/\\/g, '/') + .replace(/[?#].*$/, ''); + +const hasRestrictedPathSegment = (normalizedPath: string): boolean => { + const pathSegments = normalizedPath.split('/').filter(Boolean); + return ( + pathSegments.includes('node_modules') || pathSegments.includes('.vite') + ); +}; + +const hasParentTraversal = (normalizedPath: string): boolean => + normalizedPath.split('/').includes('..'); + +const isWindowsAbsolutePath = (normalizedPath: string): boolean => + WINDOWS_ABSOLUTE_PATH_PATTERN.test(normalizedPath); + +const isWithinSourceRoot = ( + normalizedPath: string, + normalizedSourceRoot: string, +): boolean => { + if (!normalizedSourceRoot) { + return false; + } + + return ( + normalizedPath === normalizedSourceRoot || + normalizedPath.startsWith(`${normalizedSourceRoot}/`) + ); +}; + +export const classifySourcePath = ( + filePath: string, + sourceRoot?: string, +): SourcePathClassification => { + const normalizedSourceRoot = normalizeSourceRoot(sourceRoot); + const normalizedPath = normalizeSourcePath(filePath || ''); + + if (!normalizedPath || normalizedPath.includes('\0')) { + return { + kind: 'invalid', + normalizedPath, + isSourceFile: false, + withinSourceRoot: false, + }; + } + + if (PROTOCOL_PATTERN.test(normalizedPath)) { + return { + kind: 'url', + normalizedPath, + isSourceFile: false, + withinSourceRoot: false, + }; + } + + const isSourceFile = + SOURCE_FILE_EXTENSION_PATTERN.test(normalizedPath) && + !hasRestrictedPathSegment(normalizedPath); + + if (!isSourceFile) { + return { + kind: 'invalid', + normalizedPath, + isSourceFile: false, + withinSourceRoot: false, + }; + } + + if (normalizedPath.startsWith('/@fs/')) { + const fileSystemPath = normalizedPath.slice('/@fs'.length); + return { + kind: 'vite-fs', + normalizedPath, + isSourceFile, + withinSourceRoot: isWithinSourceRoot( + fileSystemPath, + normalizedSourceRoot, + ), + }; + } + + if (isWindowsAbsolutePath(normalizedPath)) { + return { + kind: 'fs-absolute', + normalizedPath, + isSourceFile, + withinSourceRoot: isWithinSourceRoot( + normalizedPath, + normalizedSourceRoot, + ), + }; + } + + if (normalizedPath.startsWith('/')) { + const withinSourceRoot = isWithinSourceRoot( + normalizedPath, + normalizedSourceRoot, + ); + return { + kind: withinSourceRoot ? 'fs-absolute' : 'vite-root-relative', + normalizedPath, + isSourceFile, + withinSourceRoot: withinSourceRoot || !hasParentTraversal(normalizedPath), + }; + } + + return { + kind: 'project-relative', + normalizedPath, + isSourceFile, + withinSourceRoot: !hasParentTraversal(normalizedPath), + }; +}; + +const toViteFsUrl = (fileSystemPath: string): string => + fileSystemPath.startsWith('/') + ? `/@fs${fileSystemPath}` + : `/@fs/${fileSystemPath}`; + +const joinNormalizedPath = (basePath: string, childPath: string): string => + `${basePath.replace(/\/$/, '')}/${childPath.replace(/^\//, '')}`; + +export const toAbsoluteSourcePath = ( + filePath: string, + sourceRoot?: string, +): string | null => { + const normalizedSourceRoot = normalizeSourceRoot(sourceRoot); + const classifiedPath = classifySourcePath(filePath, normalizedSourceRoot); + + if (!classifiedPath.isSourceFile || !classifiedPath.withinSourceRoot) { + return null; + } + + if (classifiedPath.kind === 'vite-fs') { + return classifiedPath.normalizedPath.slice('/@fs'.length); + } + + if (classifiedPath.kind === 'fs-absolute') { + return classifiedPath.normalizedPath; + } + + if (!normalizedSourceRoot) { + return null; + } + + if (classifiedPath.kind === 'vite-root-relative') { + return joinNormalizedPath( + normalizedSourceRoot, + classifiedPath.normalizedPath, + ); + } + + if (classifiedPath.kind === 'project-relative') { + const sanitizedPath = classifiedPath.normalizedPath + .replace(/^\.?\//, '') + .replace(/\/\.\//g, '/'); + const sourceRootName = normalizedSourceRoot + .split('/') + .filter(Boolean) + .pop(); + if (sourceRootName && sanitizedPath.startsWith(`${sourceRootName}/src/`)) { + return joinNormalizedPath( + normalizedSourceRoot, + sanitizedPath.slice(sourceRootName.length + 1), + ); + } + + return joinNormalizedPath(normalizedSourceRoot, sanitizedPath); + } + + return null; +}; + +export const buildBrowserSourceCandidates = ( + filePath: string, + sourceRoot?: string, +): string[] => { + const classifiedPath = classifySourcePath(filePath, sourceRoot); + + if (!classifiedPath.isSourceFile || !classifiedPath.withinSourceRoot) { + return []; + } + + if (classifiedPath.kind === 'vite-fs') { + return [ + `${classifiedPath.normalizedPath}?raw`, + classifiedPath.normalizedPath, + ]; + } + + if (classifiedPath.kind === 'fs-absolute') { + const viteFsUrl = toViteFsUrl(classifiedPath.normalizedPath); + return [`${viteFsUrl}?raw`, viteFsUrl]; + } + + if (classifiedPath.kind === 'vite-root-relative') { + return [ + `${classifiedPath.normalizedPath}?raw`, + classifiedPath.normalizedPath, + ]; + } + + if (classifiedPath.kind === 'project-relative') { + const sanitizedPath = classifiedPath.normalizedPath.replace(/^\.?\//, ''); + const rootRelativePath = `/${sanitizedPath}`; + return [`${rootRelativePath}?raw`, rootRelativePath]; + } + + return []; +}; + +export const isAllowedProjectSourcePath = ( + filePath: string, + sourceRoot?: string, +): boolean => { + const classifiedPath = classifySourcePath(filePath, sourceRoot); + return classifiedPath.isSourceFile && classifiedPath.withinSourceRoot; +}; diff --git a/src/shared/store.ts b/packages/core/src/shared/store.ts similarity index 70% rename from src/shared/store.ts rename to packages/core/src/shared/store.ts index 01c445a..075d695 100644 --- a/src/shared/store.ts +++ b/packages/core/src/shared/store.ts @@ -1,5 +1,5 @@ -import type { WastedRenderFiberInfo } from '../types/internal'; -import type { FiberRoot } from '../types/react'; +import type { WastedRenderFiberInfo } from '../types/internal.js'; +import type { FiberRoot } from '../types/react.js'; // Closure contexts go here export const wastedRenderFiberInfo: Map = diff --git a/packages/core/src/shared/types.ts b/packages/core/src/shared/types.ts new file mode 100644 index 0000000..8d8e877 --- /dev/null +++ b/packages/core/src/shared/types.ts @@ -0,0 +1,181 @@ +import type { ZodTypeAny } from 'zod'; + +export type JsonValue = + | string + | number + | boolean + | null + | { [key: string]: JsonValue } + | JsonValue[]; + +export type ToolResultValue = string | JsonValue | undefined; + +export interface ToolkitOffset { + x?: number; + y?: number; +} + +export type ToolkitPosition = + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right'; + +export type ToolkitTuningModalStyleSlot = + | 'root' + | 'surface' + | 'panel' + | 'arrow' + | 'title' + | 'body' + | 'targetTag' + | 'customPromptForm' + | 'customPromptInput' + | 'customPromptButton' + | 'sectionTitle' + | 'row' + | 'label' + | 'controlWrap' + | 'control' + | 'colorInput' + | 'numberInput' + | 'stepperButton' + | 'select' + | 'textarea' + | 'suffix' + | 'closeButton'; + +export type ToolkitTuningModalStyleValue = string | number; + +export type ToolkitTuningModalStyle = Record< + string, + ToolkitTuningModalStyleValue +>; + +export interface ToolkitTuningModalConfig { + classNames?: Partial>; + styles?: Partial< + Record + >; + tokens?: Record; +} + +export interface ToolkitConfig { + enabled?: boolean; + defaultVisible?: boolean; + defaultExpanded?: boolean; + position?: ToolkitPosition; + offset?: ToolkitOffset; + accentColor?: string; + zIndex?: number; + iconUrl?: string; + tuningModal?: ToolkitTuningModalConfig; +} + +export interface AgenticReactConfig { + toolkit?: ToolkitConfig; + sourceRoot?: string; +} + +export interface SelectionStackFrame { + functionName: string | null; + fileName: string | null; + lineNumber: number | null; + columnNumber: number | null; +} + +export interface SelectionResolvedSource { + filePath: string; + lineNumber: number | null; + columnNumber: number | null; + componentName: string | null; +} + +export interface SelectionSourceTraceFrame { + kind: 'external' | 'project'; + componentName: string | null; + packageName: string | null; + filePath: string; + lineNumber: number | null; + columnNumber: number | null; +} + +export interface SelectionExternalComponent { + componentName: string; + packageName: string | null; + filePath: string | null; + usedBy: SelectionResolvedSource | null; +} + +export interface SelectionSourceSnippet { + filePath: string; + startLine: number; + endLine: number; + snippet: string; +} + +export interface SelectionContext { + domPreview: string; + sourcePreview: string | null; + selector: string | null; + componentName: string | null; + externalComponent: SelectionExternalComponent | null; + stackFrames: SelectionStackFrame[]; + resolvedSources: SelectionResolvedSource[]; + sourceTrace: SelectionSourceTraceFrame[]; + sourceSnippets: SelectionSourceSnippet[]; + tuningPrompts?: string[]; + capturedAt: number; +} + +export interface TuningModalContext { + element: Element; + tagName: string; + targetLabel: string; + computedStyle: CSSStyleDeclaration; + selectionContext: SelectionContext; +} + +export interface TuningModalActions { + addPrompt: (prompt: string) => void; + close: () => void; + requestReposition: () => void; +} + +export interface TuningModalSlotRenderArgs { + container: HTMLElement; + context: TuningModalContext; + actions: TuningModalActions; +} + +export interface TuningModalWrapArgs { + surfaceElement: HTMLElement; + panelElement: HTMLElement; + context: TuningModalContext; + actions: TuningModalActions; +} + +export type TuningModalExtensionCleanup = undefined | (() => void); + +export interface TuningModalExtension { + id: string; + beforeFields?: ( + args: TuningModalSlotRenderArgs, + ) => TuningModalExtensionCleanup; + afterFields?: ( + args: TuningModalSlotRenderArgs, + ) => TuningModalExtensionCleanup; + footer?: (args: TuningModalSlotRenderArgs) => TuningModalExtensionCleanup; + wrapModal?: (args: TuningModalWrapArgs) => TuningModalExtensionCleanup; +} + +export type CustomClientFunction = + | string + | ((args: unknown) => ToolResultValue | Promise); + +export interface CustomTool { + name: string; + description: string; + schema: ZodTypeAny; + clientFunction: CustomClientFunction; +} diff --git a/src/shared/util.ts b/packages/core/src/shared/util.ts similarity index 98% rename from src/shared/util.ts rename to packages/core/src/shared/util.ts index d91ba71..a4788e2 100644 --- a/src/shared/util.ts +++ b/packages/core/src/shared/util.ts @@ -4,8 +4,8 @@ import type { Props, ReactDevToolsGlobalHook, } from 'bippy'; -import type { HookNode } from '../types/internal'; -import type { FiberRoot, ReactDevtools } from '../types/react'; +import type { HookNode } from '../types/internal.js'; +import type { FiberRoot, ReactDevtools } from '../types/react.js'; import { CONCURRENT_MODE_NUMBER, CONCURRENT_MODE_SYMBOL_STRING, @@ -29,9 +29,9 @@ import { STRICT_MODE_NUMBER, STRICT_MODE_SYMBOL_STRING, target, -} from './const'; -import { ReactTypeOfWork } from './const'; -import { fiberRoots } from './store'; +} from './const.js'; +import { ReactTypeOfWork } from './const.js'; +import { fiberRoots } from './store.js'; let rdtHook: ReactDevToolsGlobalHook | ReactDevtools | null = null; export let usingRealHook = false; diff --git a/src/types/internal.ts b/packages/core/src/types/internal.ts similarity index 100% rename from src/types/internal.ts rename to packages/core/src/types/internal.ts diff --git a/src/types/react.ts b/packages/core/src/types/react.ts similarity index 100% rename from src/types/react.ts rename to packages/core/src/types/react.ts diff --git a/tsconfig.json b/packages/core/tsconfig.json similarity index 95% rename from tsconfig.json rename to packages/core/tsconfig.json index a1fba4e..971c50b 100644 --- a/tsconfig.json +++ b/packages/core/tsconfig.json @@ -9,6 +9,7 @@ "resolveJsonModule": true, "allowJs": true, "strict": true, + "skipLibCheck": true, "strictNullChecks": false, "noImplicitAny": false, "outDir": "dist", diff --git a/packages/next/LICENSE b/packages/next/LICENSE new file mode 100644 index 0000000..102207d --- /dev/null +++ b/packages/next/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 webfansplz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/next/README.md b/packages/next/README.md new file mode 100644 index 0000000..326bbc3 --- /dev/null +++ b/packages/next/README.md @@ -0,0 +1,15 @@ +# @agentic-react/next + +Next.js adapter for Agentic React local-dev MCP integration. + +```bash +pnpm install @agentic-react/next -D +``` + +```js +import withAgenticReactNext from '@agentic-react/next'; + +export default withAgenticReactNext(nextConfig); +``` + +This adapter injects `@agentic-react/core` through Next's Webpack config and starts a local bridge server for MCP. By default the bridge runs at `http://127.0.0.1:51426/mcp`. diff --git a/packages/next/package.json b/packages/next/package.json new file mode 100644 index 0000000..b77ee10 --- /dev/null +++ b/packages/next/package.json @@ -0,0 +1,53 @@ +{ + "name": "@agentic-react/next", + "type": "module", + "version": "0.1.0", + "description": "Next.js adapter for Agentic React local dev integration", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "shx rm -rf dist && tsc" + }, + "keywords": [ + "agentic-react", + "next", + "react", + "mcp", + "visual-edit", + "agents", + "devtools", + "plugin" + ], + "author": { + "name": "jazelly", + "email": "xzha4350@gmail.com" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "peerDependencies": { + "next": ">=13", + "webpack": ">=5" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + }, + "dependencies": { + "@agentic-react/core": "workspace:*", + "@modelcontextprotocol/sdk": "1.29.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } +} diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts new file mode 100644 index 0000000..8fe2d8d --- /dev/null +++ b/packages/next/src/index.ts @@ -0,0 +1,312 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { RuntimeBridgeServer } from '@agentic-react/core/bridge'; +import { + createStreamableHttpMcpHandler, + initMcpServer, +} from '@agentic-react/core/mcp'; +import { + __AGENTIC_REACT_BRIDGE_URL__, + __AGENTIC_REACT_CONFIG__, +} from '@agentic-react/core/shared/const'; +import { + generateCustomToolsScript, + toBundledClientImportSpecifier, + toRelativeImportSpecifier, +} from '@agentic-react/core/shared/custom-tools-script'; +import { BRIDGE_WS_PATH } from '@agentic-react/core/shared/protocol'; +import type { + CustomTool, + ToolkitConfig, +} from '@agentic-react/core/shared/types'; + +interface NextWebpackContext { + dev: boolean; + isServer: boolean; + webpack: { + DefinePlugin: new (definitions: Record) => unknown; + }; +} + +interface NextConfig { + webpack?: (config: any, context: NextWebpackContext) => any; + [key: string]: unknown; +} + +export interface AgenticReactNextOptions { + customTools?: CustomTool[]; + rootDir?: string; + toolkit?: ToolkitConfig; + bridgeUrl?: string; + server?: { + enabled?: boolean; + host?: string; + port?: number; + }; +} + +interface BridgeServerState { + started: boolean; + server?: http.Server; +} + +const DEFAULT_BRIDGE_HOST = '127.0.0.1'; +const DEFAULT_BRIDGE_PORT = 51426; +const NEXT_BRIDGE_STATE_KEY = Symbol.for('agentic-react.nextBridgeServers'); + +const require = createRequire(import.meta.url); + +const getCoreDistPath = () => + path.dirname(require.resolve('@agentic-react/core/overlay')); + +const writeNextClientEntry = ( + rootDir: string, + bridgeUrl: string | null, + customTools: CustomTool[], + toolkitConfig: ToolkitConfig, +): string => { + const coreDistPath = getCoreDistPath(); + const generatedDirectory = path.join(rootDir, '.next/agentic-react'); + const generatedEntryPath = path.join(generatedDirectory, 'client-entry.mjs'); + const constSpecifier = toRelativeImportSpecifier( + generatedDirectory, + path.join(coreDistPath, 'shared/const.js'), + ); + const protocolSpecifier = toRelativeImportSpecifier( + generatedDirectory, + path.join(coreDistPath, 'shared/protocol.js'), + ); + const overlaySpecifier = toRelativeImportSpecifier( + generatedDirectory, + path.join(coreDistPath, 'overlay.js'), + ); + const customToolsScript = generateCustomToolsScript( + customTools, + (specifier) => + toBundledClientImportSpecifier(rootDir, generatedDirectory, specifier), + ); + + const entrySource = ` +import { + ${__AGENTIC_REACT_BRIDGE_URL__}, + ${__AGENTIC_REACT_CONFIG__}, +} from ${JSON.stringify(constSpecifier)}; +import { BRIDGE_WS_PATH } from ${JSON.stringify(protocolSpecifier)}; + +if (typeof window !== 'undefined') { + const existingAgenticReactConfig = window[${__AGENTIC_REACT_CONFIG__}] || {}; + window[${__AGENTIC_REACT_CONFIG__}] = { + ...existingAgenticReactConfig, + sourceRoot: existingAgenticReactConfig.sourceRoot || ${JSON.stringify(rootDir)}, + toolkit: { + ...(existingAgenticReactConfig.toolkit || {}), + ...${JSON.stringify(toolkitConfig)}, + }, + }; + + if (!window[${__AGENTIC_REACT_BRIDGE_URL__}]) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + window[${__AGENTIC_REACT_BRIDGE_URL__}] = + ${JSON.stringify(bridgeUrl || '')} || + \`\${protocol}//\${window.location.host}\${BRIDGE_WS_PATH}\`; + } +} + +void import(${JSON.stringify(overlaySpecifier)}); + +${customToolsScript} +`; + + fs.mkdirSync(generatedDirectory, { recursive: true }); + if ( + !fs.existsSync(generatedEntryPath) || + fs.readFileSync(generatedEntryPath, 'utf8') !== entrySource + ) { + fs.writeFileSync(generatedEntryPath, entrySource); + } + + return generatedEntryPath; +}; + +const prependEntry = (entryValue: unknown, prependPath: string): unknown => { + if (typeof entryValue === 'string') { + return [prependPath, entryValue]; + } + + if (Array.isArray(entryValue)) { + return entryValue.includes(prependPath) + ? entryValue + : [prependPath, ...entryValue]; + } + + if (entryValue && typeof entryValue === 'object') { + const entryObject = entryValue as Record; + const nextEntryObject: Record = {}; + for (const [key, value] of Object.entries(entryObject)) { + if (key.startsWith('webpack-hot-middleware') || key === prependPath) { + nextEntryObject[key] = value; + continue; + } + + nextEntryObject[key] = prependEntry(value, prependPath); + } + return nextEntryObject; + } + + return entryValue; +}; + +const getBridgeServerRegistry = (): Map => { + const globalRecord = globalThis as Record< + symbol, + Map + >; + if (!globalRecord[NEXT_BRIDGE_STATE_KEY]) { + globalRecord[NEXT_BRIDGE_STATE_KEY] = new Map(); + } + return globalRecord[NEXT_BRIDGE_STATE_KEY]; +}; + +const createBridgeHttpServer = (rootDir: string, customTools: CustomTool[]) => { + const runtimeBridge = new RuntimeBridgeServer(); + const handleMcpRequest = createStreamableHttpMcpHandler(() => + initMcpServer(runtimeBridge, rootDir, customTools), + ); + + const httpServer = http.createServer(async (req, res) => { + const requestUrl = req.url || '/'; + const requestPath = requestUrl.split('?')[0]; + + if (requestPath === '/health') { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (requestPath === '/mcp') { + await handleMcpRequest(req, res); + return; + } + + res.statusCode = 404; + res.end('Not Found'); + }); + + runtimeBridge.attach(httpServer); + return httpServer; +}; + +const startNextBridgeServer = ( + options: AgenticReactNextOptions, +): string | null => { + if (options.server?.enabled === false) { + return options.bridgeUrl || null; + } + + const host = options.server?.host || DEFAULT_BRIDGE_HOST; + const port = options.server?.port || DEFAULT_BRIDGE_PORT; + const bridgeUrl = + options.bridgeUrl || `ws://${host}:${port}${BRIDGE_WS_PATH}`; + const registryKey = `${host}:${port}`; + const registry = getBridgeServerRegistry(); + const existingState = registry.get(registryKey); + + if (existingState) { + return bridgeUrl; + } + + const serverState: BridgeServerState = { started: false }; + registry.set(registryKey, serverState); + + const rootDir = options.rootDir || process.cwd(); + const httpServer = createBridgeHttpServer(rootDir, options.customTools || []); + serverState.server = httpServer; + + httpServer.once('error', (error) => { + serverState.started = false; + registry.delete(registryKey); + console.warn( + `[agentic-react] Next bridge server failed on ${host}:${port}:`, + error instanceof Error ? error.message : error, + ); + }); + + httpServer.listen(port, host, () => { + serverState.started = true; + console.info( + `[agentic-react] Next MCP bridge listening on http://${host}:${port}`, + ); + }); + + return bridgeUrl; +}; + +export const withAgenticReactNext = ( + nextConfig: NextConfig = {}, + options: AgenticReactNextOptions = {}, +): NextConfig => { + return { + ...nextConfig, + webpack: (config: any, context: NextWebpackContext) => { + const nextConfigWebpack = nextConfig.webpack; + const transformedConfig = + typeof nextConfigWebpack === 'function' + ? nextConfigWebpack(config, context) + : config; + + if (!context.dev || context.isServer) { + return transformedConfig; + } + + const rootDir = options.rootDir || process.cwd(); + const bridgeUrl = startNextBridgeServer(options); + const entryPath = writeNextClientEntry( + rootDir, + bridgeUrl, + options.customTools || [], + options.toolkit || {}, + ); + const originalEntry = transformedConfig.entry; + + transformedConfig.entry = async () => { + const resolvedEntry = + typeof originalEntry === 'function' + ? await originalEntry() + : originalEntry; + + return prependEntry(resolvedEntry, entryPath); + }; + + return transformedConfig; + }, + }; +}; + +export default withAgenticReactNext; +export type { + AgenticReactConfig, + CustomClientFunction, + CustomTool, + JsonValue, + SelectionContext, + SelectionResolvedSource, + SelectionSourceSnippet, + SelectionStackFrame, + ToolkitConfig, + ToolkitOffset, + ToolkitPosition, + ToolkitTuningModalConfig, + ToolkitTuningModalStyle, + ToolkitTuningModalStyleSlot, + ToolkitTuningModalStyleValue, + ToolResultValue, + TuningModalActions, + TuningModalContext, + TuningModalExtension, + TuningModalExtensionCleanup, + TuningModalSlotRenderArgs, + TuningModalWrapArgs, +} from '@agentic-react/core/shared/types'; diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json new file mode 100644 index 0000000..d956eb8 --- /dev/null +++ b/packages/next/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["esnext", "dom"], + "module": "esnext", + "moduleResolution": "bundler", + "declaration": true, + "strict": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "outDir": "dist", + "esModuleInterop": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/vite/LICENSE b/packages/vite/LICENSE new file mode 100644 index 0000000..102207d --- /dev/null +++ b/packages/vite/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 webfansplz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/vite/README.md b/packages/vite/README.md new file mode 100644 index 0000000..d9c1f72 --- /dev/null +++ b/packages/vite/README.md @@ -0,0 +1,18 @@ +# @agentic-react/vite + +Vite adapter for Agentic React local-dev MCP integration. + +```bash +pnpm install @agentic-react/vite -D +``` + +```ts +import { defineConfig } from 'vite'; +import AgenticReact from '@agentic-react/vite'; + +export default defineConfig({ + plugins: [AgenticReact()], +}); +``` + +This adapter injects `@agentic-react/core` into the browser runtime, attaches the local runtime bridge to the Vite dev server, and exposes MCP over Streamable HTTP at `/mcp`. diff --git a/packages/vite/package.json b/packages/vite/package.json new file mode 100644 index 0000000..2e7ca6d --- /dev/null +++ b/packages/vite/package.json @@ -0,0 +1,50 @@ +{ + "name": "@agentic-react/vite", + "type": "module", + "version": "0.1.0", + "description": "Vite adapter for Agentic React local dev integration", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "shx rm -rf dist && tsc" + }, + "keywords": [ + "agentic-react", + "vite", + "react", + "mcp", + "visual-edit", + "agents", + "devtools", + "plugin" + ], + "author": { + "name": "jazelly", + "email": "xzha4350@gmail.com" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "peerDependencies": { + "vite": ">=4" + }, + "devDependencies": { + "vite": "^6.4.2" + }, + "dependencies": { + "@agentic-react/core": "workspace:*", + "@modelcontextprotocol/sdk": "1.29.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } +} diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts new file mode 100644 index 0000000..e3f071f --- /dev/null +++ b/packages/vite/src/index.ts @@ -0,0 +1,203 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { RuntimeBridgeServer } from '@agentic-react/core/bridge'; +import { + createStreamableHttpMcpHandler, + initMcpServer, +} from '@agentic-react/core/mcp'; +import { + __AGENTIC_REACT_BRIDGE_URL__, + __AGENTIC_REACT_CONFIG__, +} from '@agentic-react/core/shared/const'; +import { generateCustomToolsScript } from '@agentic-react/core/shared/custom-tools-script'; +import { BRIDGE_WS_PATH } from '@agentic-react/core/shared/protocol'; +import type { + CustomTool, + ToolkitConfig, +} from '@agentic-react/core/shared/types'; +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { type ViteDevServer, normalizePath } from 'vite'; +import type { Plugin, ResolvedConfig } from 'vite'; + +const require = createRequire(import.meta.url); + +function getAgenticReactCorePath() { + return normalizePath( + path.dirname(require.resolve('@agentic-react/core/overlay')), + ); +} + +const agenticReactCorePath = getAgenticReactCorePath(); +const agenticReactResourceSymbol = '?__agentic-react-resource'; +const agenticReactImportee = 'virtual:agentic-react'; +const resolvedAgenticReact = `\0${agenticReactImportee}`; + +function instrumentViteDevServer( + viteDevServer: ViteDevServer, + createMcpServer: () => Server, +) { + viteDevServer.middlewares.use( + '/mcp', + createStreamableHttpMcpHandler(createMcpServer), + ); +} + +export interface AgenticReactOptions { + customTools?: CustomTool[]; + toolkit?: ToolkitConfig; +} + +function AgenticReact(options: AgenticReactOptions = {}): Plugin { + let config: ResolvedConfig; + const customTools = options.customTools || []; + const toolkitConfig = options.toolkit || {}; + + return { + name: 'agentic-react', + enforce: 'pre', + apply: 'serve', + + configureServer(viteDevServer: ViteDevServer) { + const runtimeBridge = new RuntimeBridgeServer(); + if (viteDevServer.httpServer) { + runtimeBridge.attach(viteDevServer.httpServer); + } + + const createMcpServer = () => + initMcpServer(runtimeBridge, viteDevServer.config.root, customTools); + instrumentViteDevServer(viteDevServer, createMcpServer); + + setTimeout(() => { + console.info( + 'Agentic React MCP server is running on port', + viteDevServer.config.server.port, + ); + }, 1000); + }, + + async resolveId(importee) { + if (importee === agenticReactImportee) { + return resolvedAgenticReact; + } + if (importee.startsWith(`${agenticReactImportee}:`)) { + const resolved = importee.replace( + `${agenticReactImportee}:`, + `${agenticReactCorePath}/`, + ); + return `${resolved}${agenticReactResourceSymbol}`; + } + }, + + configResolved(resolvedConfig) { + config = resolvedConfig; + }, + + transformIndexHtml() { + const registerAgenticReactConfigScript = ` + window.${__AGENTIC_REACT_CONFIG__} = ${JSON.stringify({ + toolkit: toolkitConfig, + sourceRoot: config.root, + })}; + `; + + const bridgeUrlScript = ` + (function(){ + var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + window.${__AGENTIC_REACT_BRIDGE_URL__} = protocol + '//' + window.location.host + '${BRIDGE_WS_PATH}'; + })(); + `; + + const customToolsScript = generateCustomToolsScript( + customTools, + (specifier) => toClientImportSpecifier(specifier, config), + ); + + return [ + { + tag: 'script', + injectTo: 'head-prepend', + attrs: { type: 'text/javascript' }, + children: registerAgenticReactConfigScript, + }, + { + tag: 'script', + injectTo: 'head-prepend', + attrs: { type: 'text/javascript' }, + children: bridgeUrlScript, + }, + { + tag: 'script', + injectTo: 'head-prepend', + attrs: { + type: 'module', + src: `${config.base || '/'}@id/${agenticReactImportee}:overlay.js`, + }, + }, + { + tag: 'script', + injectTo: 'head-prepend', + attrs: { type: 'module' }, + children: customToolsScript, + }, + ]; + }, + }; +} + +function toClientImportSpecifier( + filePathOrSpecifier: string, + resolvedConfig: ResolvedConfig, +): string { + const normalized = normalizePath(filePathOrSpecifier); + + if ( + normalized.startsWith('/@fs/') || + normalized.startsWith('http://') || + normalized.startsWith('https://') + ) { + return normalized; + } + + if (path.isAbsolute(normalized)) { + return `/@fs/${normalized}`; + } + + if (normalized.startsWith('./') || normalized.startsWith('../')) { + const absolutePath = normalizePath( + path.resolve(resolvedConfig.root, normalized), + ); + return `/@fs/${absolutePath}`; + } + + if (normalized.startsWith('/')) { + return normalized; + } + + return `/@id/${normalized}`; +} + +export default AgenticReact; +export type { + AgenticReactConfig, + CustomClientFunction, + CustomTool, + JsonValue, + SelectionContext, + SelectionResolvedSource, + SelectionSourceSnippet, + SelectionStackFrame, + ToolkitConfig, + ToolkitOffset, + ToolkitPosition, + ToolkitTuningModalConfig, + ToolkitTuningModalStyle, + ToolkitTuningModalStyleSlot, + ToolkitTuningModalStyleValue, + ToolResultValue, + TuningModalActions, + TuningModalContext, + TuningModalExtension, + TuningModalExtensionCleanup, + TuningModalSlotRenderArgs, + TuningModalWrapArgs, +} from '@agentic-react/core/shared/types'; diff --git a/packages/vite/tsconfig.json b/packages/vite/tsconfig.json new file mode 100644 index 0000000..d956eb8 --- /dev/null +++ b/packages/vite/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["esnext", "dom"], + "module": "esnext", + "moduleResolution": "bundler", + "declaration": true, + "strict": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "outDir": "dist", + "esModuleInterop": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/webpack/LICENSE b/packages/webpack/LICENSE new file mode 100644 index 0000000..102207d --- /dev/null +++ b/packages/webpack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 webfansplz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/webpack/README.md b/packages/webpack/README.md new file mode 100644 index 0000000..d0b30d7 --- /dev/null +++ b/packages/webpack/README.md @@ -0,0 +1,16 @@ +# @agentic-react/webpack + +Webpack adapter for Agentic React local-dev MCP integration. + +```bash +pnpm install @agentic-react/webpack -D +``` + +```js +import withAgenticReactWebpack from '@agentic-react/webpack'; + +export default (env, argv) => + withAgenticReactWebpack(config, { mode: argv.mode }); +``` + +This adapter injects `@agentic-react/core` through a generated browser entry, attaches the local runtime bridge to webpack-dev-server, and exposes MCP over Streamable HTTP at `/mcp`. diff --git a/packages/webpack/package.json b/packages/webpack/package.json new file mode 100644 index 0000000..2b61f36 --- /dev/null +++ b/packages/webpack/package.json @@ -0,0 +1,47 @@ +{ + "name": "@agentic-react/webpack", + "type": "module", + "version": "0.1.0", + "description": "Webpack adapter for Agentic React local dev integration", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "shx rm -rf dist && tsc" + }, + "keywords": [ + "agentic-react", + "webpack", + "react", + "mcp", + "visual-edit", + "agents", + "devtools", + "plugin" + ], + "author": { + "name": "jazelly", + "email": "xzha4350@gmail.com" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "peerDependencies": { + "webpack": ">=5" + }, + "dependencies": { + "@agentic-react/core": "workspace:*", + "@modelcontextprotocol/sdk": "1.29.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } +} diff --git a/packages/webpack/src/index.ts b/packages/webpack/src/index.ts new file mode 100644 index 0000000..7e9598d --- /dev/null +++ b/packages/webpack/src/index.ts @@ -0,0 +1,400 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { RuntimeBridgeServer } from '@agentic-react/core/bridge'; +import { + createStreamableHttpMcpHandler, + initMcpServer, +} from '@agentic-react/core/mcp'; +import { + __AGENTIC_REACT_BRIDGE_URL__, + __AGENTIC_REACT_CONFIG__, +} from '@agentic-react/core/shared/const'; +import { + generateCustomToolsScript, + toBundledClientImportSpecifier, + toRelativeImportSpecifier, +} from '@agentic-react/core/shared/custom-tools-script'; +import { SOURCE_LOOKUP_PATH } from '@agentic-react/core/shared/protocol'; +import type { + CustomTool, + SelectionResolvedSource, + ToolkitConfig, +} from '@agentic-react/core/shared/types'; +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; + +interface WebpackEnv { + mode?: string; +} + +export interface AgenticReactWebpackOptions { + customTools?: CustomTool[]; + rootDir?: string; + toolkit?: ToolkitConfig; +} + +type WebpackDevServer = { + server?: unknown; +}; + +const require = createRequire(import.meta.url); + +const getCoreDistPath = () => + path.dirname(require.resolve('@agentic-react/core/overlay')); + +const writeWebpackClientEntry = ( + rootDir: string, + customTools: CustomTool[], + toolkitConfig: ToolkitConfig, +): string => { + const coreDistPath = getCoreDistPath(); + const generatedDirectory = path.join(rootDir, '.agentic-react-webpack'); + const generatedEntryPath = path.join(generatedDirectory, 'client-entry.mjs'); + const constSpecifier = toRelativeImportSpecifier( + generatedDirectory, + path.join(coreDistPath, 'shared/const.js'), + ); + const protocolSpecifier = toRelativeImportSpecifier( + generatedDirectory, + path.join(coreDistPath, 'shared/protocol.js'), + ); + const overlaySpecifier = toRelativeImportSpecifier( + generatedDirectory, + path.join(coreDistPath, 'overlay.js'), + ); + const customToolsScript = generateCustomToolsScript( + customTools, + (specifier) => + toBundledClientImportSpecifier(rootDir, generatedDirectory, specifier), + ); + + const entrySource = ` +import { + ${__AGENTIC_REACT_BRIDGE_URL__}, + ${__AGENTIC_REACT_CONFIG__}, +} from ${JSON.stringify(constSpecifier)}; +import { BRIDGE_WS_PATH } from ${JSON.stringify(protocolSpecifier)}; + +if (typeof window !== 'undefined') { + const existingAgenticReactConfig = window[${__AGENTIC_REACT_CONFIG__}] || {}; + window[${__AGENTIC_REACT_CONFIG__}] = { + ...existingAgenticReactConfig, + sourceRoot: existingAgenticReactConfig.sourceRoot || ${JSON.stringify(rootDir)}, + toolkit: { + ...(existingAgenticReactConfig.toolkit || {}), + ...${JSON.stringify(toolkitConfig)}, + }, + }; + + if (!window[${__AGENTIC_REACT_BRIDGE_URL__}]) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + window[${__AGENTIC_REACT_BRIDGE_URL__}] = + \`\${protocol}//\${window.location.host}\${BRIDGE_WS_PATH}\`; + } +} + +void import(${JSON.stringify(overlaySpecifier)}); + +${customToolsScript} +`; + + fs.mkdirSync(generatedDirectory, { recursive: true }); + if ( + !fs.existsSync(generatedEntryPath) || + fs.readFileSync(generatedEntryPath, 'utf8') !== entrySource + ) { + fs.writeFileSync(generatedEntryPath, entrySource); + } + + return generatedEntryPath; +}; + +const prependWebpackEntry = ( + entryValue: unknown, + prependPath: string, +): unknown => { + if (typeof entryValue === 'string') { + return [prependPath, entryValue]; + } + + if (Array.isArray(entryValue)) { + return entryValue.includes(prependPath) + ? entryValue + : [prependPath, ...entryValue]; + } + + if (entryValue && typeof entryValue === 'object') { + const entryObject = entryValue as Record; + const nextEntryObject: Record = {}; + for (const [key, value] of Object.entries(entryObject)) { + nextEntryObject[key] = prependWebpackEntry(value, prependPath); + } + return nextEntryObject; + } + + return entryValue; +}; + +const LOCAL_COMPONENT_NAME_PATTERN = /^[A-Z][A-Za-z0-9_$]*$/; + +const stripCssEscapes = (value: string): string => + value.replace(/\\([^\r\n])/g, '$1'); + +const pushSourceHint = (hints: string[], hint: string | null | undefined) => { + if (!hint || hint.length < 3 || hints.includes(hint)) return; + hints.push(hint); +}; + +const extractSelectorHints = (selector: string): string[] => { + const hints: string[] = []; + const idMatch = selector.match(/#((?:\\.|[^\s>+~.#:[\]])+)/); + pushSourceHint(hints, idMatch?.[1] ? stripCssEscapes(idMatch[1]) : null); + + const classHints = Array.from( + selector.matchAll(/\.((?:\\.|[^\s>+~.#:[\]])+)/g), + (match) => stripCssEscapes(match[1]), + ); + for (const classHint of classHints.reverse()) { + pushSourceHint(hints, classHint); + } + + return hints; +}; + +const isSourceFileName = (fileName: string): boolean => + /\.(?:jsx?|tsx?)$/i.test(fileName); + +const walkProjectSourceFiles = ( + directory: string, + visit: (filePath: string) => boolean, +): boolean => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch (_error) { + return false; + } + + for (const entry of entries) { + if ( + entry.name === 'node_modules' || + entry.name === '.git' || + entry.name === 'dist' || + entry.name === 'tmp' + ) { + continue; + } + + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (walkProjectSourceFiles(entryPath, visit)) return true; + continue; + } + + if (!isSourceFileName(entry.name)) continue; + if (visit(entryPath)) return true; + } + + return false; +}; + +const findComponentSourceInProject = ( + rootDir: string, + componentName: string, + selector: string, +): SelectionResolvedSource | null => { + if (!LOCAL_COMPONENT_NAME_PATTERN.test(componentName)) return null; + + const declarationPattern = new RegExp( + `(?:function\\s+${componentName}\\b|const\\s+${componentName}\\s*=|class\\s+${componentName}\\b)`, + ); + const hints = extractSelectorHints(selector); + const normalizedRootDir = fs.realpathSync(rootDir); + let matchedSource: SelectionResolvedSource | null = null; + + walkProjectSourceFiles(rootDir, (filePath) => { + let sourceText: string; + try { + sourceText = fs.readFileSync(filePath, 'utf8'); + } catch (_error) { + return false; + } + + if (!declarationPattern.test(sourceText)) return false; + + const lines = sourceText.split(/\r?\n/); + const declarationIndex = lines.findIndex((line) => + declarationPattern.test(line), + ); + let lineIndex = declarationIndex >= 0 ? declarationIndex : 0; + const searchStartIndex = Math.max(0, lineIndex); + const searchRanges = [ + lines.slice(searchStartIndex).map((line, index) => ({ + line, + lineIndex: searchStartIndex + index, + })), + lines.slice(0, searchStartIndex).map((line, index) => ({ + line, + lineIndex: index, + })), + ]; + + for (const hint of hints) { + for (const searchRange of searchRanges) { + const matchedLine = searchRange.find(({ line }) => line.includes(hint)); + if (matchedLine) { + lineIndex = matchedLine.lineIndex; + break; + } + } + if (lineIndex !== declarationIndex) break; + } + + matchedSource = { + filePath: path + .relative(normalizedRootDir, fs.realpathSync(filePath)) + .replace(/\\/g, '/'), + lineNumber: lineIndex + 1, + columnNumber: null, + componentName, + }; + return true; + }); + + return matchedSource; +}; + +const createSourceLookupMiddleware = (rootDir: string) => ({ + name: 'agentic-react-source-lookup', + path: SOURCE_LOOKUP_PATH, + middleware: (req: any, res: any) => { + if (req.method !== 'GET') { + res.statusCode = 405; + res.end('Method Not Allowed'); + return; + } + + const requestUrl = new URL( + req.url || SOURCE_LOOKUP_PATH, + 'http://127.0.0.1', + ); + const componentName = requestUrl.searchParams.get('component') || ''; + const selector = requestUrl.searchParams.get('selector') || ''; + const source = findComponentSourceInProject( + rootDir, + componentName, + selector, + ); + + if (!source) { + res.statusCode = 404; + res.end('Not Found'); + return; + } + + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(source)); + }, +}); + +const createMcpMiddlewares = (createMcpServer: () => Server) => { + return [ + { + name: 'agentic-react-mcp', + path: '/mcp', + middleware: createStreamableHttpMcpHandler(createMcpServer), + }, + ]; +}; + +export const withAgenticReactWebpack = ( + config: Record, + env: WebpackEnv = {}, + options: AgenticReactWebpackOptions = {}, +) => { + const isDevelopmentMode = env.mode ? env.mode === 'development' : true; + if (!isDevelopmentMode) { + return config; + } + + const nextConfig = { ...config }; + const rootDir = + options.rootDir || + (typeof nextConfig.context === 'string' + ? nextConfig.context + : process.cwd()); + const entryPath = writeWebpackClientEntry( + rootDir, + options.customTools || [], + options.toolkit || {}, + ); + const runtimeBridge = new RuntimeBridgeServer(); + const createMcpServer = () => + initMcpServer(runtimeBridge, rootDir, options.customTools || []); + const mcpMiddlewares = [ + createSourceLookupMiddleware(rootDir), + ...createMcpMiddlewares(createMcpServer), + ]; + + nextConfig.entry = prependWebpackEntry(nextConfig.entry, entryPath); + + const devServer = + nextConfig.devServer && typeof nextConfig.devServer === 'object' + ? { ...(nextConfig.devServer as Record) } + : {}; + const existingSetupMiddlewares = devServer.setupMiddlewares; + const existingOnListening = devServer.onListening; + let bridgeAttached = false; + + devServer.setupMiddlewares = ( + middlewares: unknown[], + webpackDevServer: WebpackDevServer, + ) => { + const configuredMiddlewares = + typeof existingSetupMiddlewares === 'function' + ? existingSetupMiddlewares(middlewares, webpackDevServer) + : middlewares; + + return [...mcpMiddlewares, ...configuredMiddlewares]; + }; + + devServer.onListening = (webpackDevServer: WebpackDevServer) => { + if (!bridgeAttached && webpackDevServer.server) { + runtimeBridge.attach(webpackDevServer.server); + bridgeAttached = true; + } + + if (typeof existingOnListening === 'function') { + existingOnListening(webpackDevServer); + } + }; + + nextConfig.devServer = devServer; + return nextConfig; +}; + +export default withAgenticReactWebpack; +export type { + AgenticReactConfig, + CustomClientFunction, + CustomTool, + JsonValue, + SelectionContext, + SelectionResolvedSource, + SelectionSourceSnippet, + SelectionStackFrame, + ToolkitConfig, + ToolkitOffset, + ToolkitPosition, + ToolkitTuningModalConfig, + ToolkitTuningModalStyle, + ToolkitTuningModalStyleSlot, + ToolkitTuningModalStyleValue, + ToolResultValue, + TuningModalActions, + TuningModalContext, + TuningModalExtension, + TuningModalExtensionCleanup, + TuningModalSlotRenderArgs, + TuningModalWrapArgs, +} from '@agentic-react/core/shared/types'; diff --git a/packages/webpack/tsconfig.json b/packages/webpack/tsconfig.json new file mode 100644 index 0000000..c8d64ee --- /dev/null +++ b/packages/webpack/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["esnext", "dom"], + "module": "esnext", + "moduleResolution": "bundler", + "declaration": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "esModuleInterop": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/playground/agentic-react-next-playground/app/globals.css b/playground/agentic-react-next-playground/app/globals.css new file mode 100644 index 0000000..673ba52 --- /dev/null +++ b/playground/agentic-react-next-playground/app/globals.css @@ -0,0 +1,17 @@ +html, +body { + margin: 0; + min-width: 320px; + background: #f5f7fb; +} + +body { + color: #152033; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} diff --git a/playground/agentic-react-next-playground/app/layout.jsx b/playground/agentic-react-next-playground/app/layout.jsx new file mode 100644 index 0000000..1156aa6 --- /dev/null +++ b/playground/agentic-react-next-playground/app/layout.jsx @@ -0,0 +1,14 @@ +import 'antd/dist/reset.css'; +import './globals.css'; + +export const metadata = { + title: 'Next Playground', +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} diff --git a/playground/agentic-react-next-playground/app/page.jsx b/playground/agentic-react-next-playground/app/page.jsx new file mode 100644 index 0000000..c748835 --- /dev/null +++ b/playground/agentic-react-next-playground/app/page.jsx @@ -0,0 +1,403 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + ArrowsClockwise, + BellRinging, + BracketsCurly, + ChartLineUp, + CheckCircle, + Code, + Database, + GitBranch, + MagnifyingGlass, + RocketLaunch, + ShieldCheck, +} from '@phosphor-icons/react'; +import { + Alert, + Badge, + Button, + Card, + Col, + ConfigProvider, + Descriptions, + Divider, + Flex, + Form, + Input, + Layout, + Menu, + Progress, + Row, + Segmented, + Space, + Statistic, + Table, + Tabs, + Tag, + Timeline, + Typography, + theme, +} from 'antd'; + +const { Content, Header, Sider } = Layout; +const { Text, Title } = Typography; + +const navigation = [ + { key: 'overview', icon: , label: 'Overview' }, + { key: 'selectors', icon: , label: 'Selectors' }, + { key: 'pipelines', icon: , label: 'Pipelines' }, + { key: 'security', icon: , label: 'Security' }, +]; + +const selectorRows = [ + { + key: 'copy-target', + selector: '#next-copy-target', + owner: 'OperationsNarrative', + confidence: 98, + status: 'release gate', + }, + { + key: 'counter', + selector: '#next-counter', + owner: 'RunbookReplayAction', + confidence: 94, + status: 'stateful', + }, + { + key: 'title', + selector: '#next-title', + owner: 'IncidentCommandHeader', + confidence: 91, + status: 'narrative anchor', + }, +]; + +const incidents = [ + 'Checkout conversion route promoted after hydration variance dropped below the release threshold', + 'Customer-risk panel retained stable selectors after Ant Design token overrides were applied', + 'Runbook replay captured source context from the incident narrative and primary action', + 'Selection source test content for Next.', +]; + +function AntShell({ children }) { + return ( + + {children} + + ); +} + +function IconBadge({ icon, tone = '#2557d6' }) { + return ( + + {icon} + + ); +} + +function MetricTile({ icon, label, value, suffix, tone }) { + return ( + + + + + + + ); +} + +function SectionCard({ title, icon, extra, children }) { + return ( + + + {title} + + } + extra={extra} + > + {children} + + ); +} + +function SelectorTable({ rows }) { + const columns = [ + { + title: 'Selector', + dataIndex: 'selector', + render: (value) => {value}, + }, + { + title: 'Owner', + dataIndex: 'owner', + }, + { + title: 'Confidence', + dataIndex: 'confidence', + render: (value) => , + }, + { + title: 'Status', + dataIndex: 'status', + render: (value) => {value}, + }, + ]; + + return ; +} + +export default function HomePage() { + const [count, setCount] = useState(0); + const [mode, setMode] = useState('Live'); + const [activeNav, setActiveNav] = useState('overview'); + + const health = useMemo(() => Math.min(99, 87 + count), [count]); + + return ( + + + + + } tone="#7dd3fc" /> + Next Ops + + setActiveNav(key)} + /> + + + Runtime bridge + + + Inspecting command-center routes, release gates, and selector provenance. + + + + + + + +
+ + + Production selector lab + + +
+ + + +
+ +
+ Next.js + Ant Design + Phosphor + + Next Playground + + + Selection source test content for Next. + + + This command center models a real release-readiness workflow: + selector confidence, hydration safety, customer-risk telemetry, + runbook replay, and incident audit trails are all rendered through + nested Ant Design components. + +
+ +
+
+
+ + +
+ + Release intelligence workspace + + + The seeded data represents a mature product engineering scenario: + a customer-facing deployment is moving through verification while + selectors, source context, alerts, and ownership are reviewed in one + operational surface. + +
+
+ + + + +
+ } label="Selector health" value={health} suffix="%" tone="#2557d6" /> + + + } label="Hydrated islands" value={12 + count} tone="#7c3aed" /> + + + } label="Open notices" value={3} tone="#b76e00" /> + + + + + + } + extra={{mode}} + > + + + + + }> + ({ + color: index === incidents.length - 1 ? 'green' : 'blue', + content: item, + }))} + /> + ), + }, + { + key: 'form', + label: 'Runbook', + children: ( +
+ + } defaultValue="#next-copy-target" /> + + + } defaultValue="HomePage" /> + + + ), + }, + ]} + /> +
+ + + + + + }> +
+ {[ + 'Hydration stable on account overview, billing, and entitlement routes', + 'Runtime globals injected before runbook replay begins', + 'Copy context available for selectors used in release gates', + 'Selection mode verified against source-owned narrative and action components', + ].map((item) => ( +
+ + + {item} + +
+ ))} +
+
+ + + + + + + + + + ); +} diff --git a/playground/agentic-react-next-playground/e2e/runtime.spec.js b/playground/agentic-react-next-playground/e2e/runtime.spec.js new file mode 100644 index 0000000..d5e7af3 --- /dev/null +++ b/playground/agentic-react-next-playground/e2e/runtime.spec.js @@ -0,0 +1,146 @@ +import { expect, test } from '@playwright/test'; + +const bridgePort = process.env.AGENTIC_REACT_NEXT_BRIDGE_PORT || '51426'; +const MCP_SERVER_URL = `http://127.0.0.1:${bridgePort}/mcp`; + +const createMcpClient = async () => { + const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([ + import('@modelcontextprotocol/sdk/client/index.js'), + import('@modelcontextprotocol/sdk/client/streamableHttp.js'), + ]); + const client = new Client({ + name: 'next-playground-mcp-e2e', + version: '0.0.0', + }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL)); + await client.connect(transport); + return { client, transport }; +}; + +const parseToolResponse = (result) => { + const textContent = result.content.find((content) => content.type === 'text'); + if (!textContent || typeof textContent.text !== 'string') { + return null; + } + return JSON.parse(textContent.text); +}; + +test('next playground injects runtime globals', async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const runtimeShape = await page.evaluate(() => ({ + hasRuntime: Boolean(window.__AGENTIC_REACT__), + hasTools: Boolean(window.__AGENTIC_REACT_TOOLS__), + hasSelectionMode: typeof window.__AGENTIC_REACT__?.setSelectionMode, + hasGetContext: typeof window.__AGENTIC_REACT__?.getLastSelectionContext, + hasCopyContext: typeof window.__AGENTIC_REACT__?.copyLastSelectionContext, + hasRegisterTuningModalExtension: + typeof window.__AGENTIC_REACT__?.registerTuningModalExtension, + tuningSurfaceClassName: + window.__AGENTIC_REACT_CONFIG__?.toolkit?.tuningModal?.classNames + ?.surface, + tuningPanelClassName: + window.__AGENTIC_REACT_CONFIG__?.toolkit?.tuningModal?.classNames?.panel, + unregisterType: (() => { + const unregister = + window.__AGENTIC_REACT__?.registerTuningModalExtension({ + id: 'next-runtime-smoke', + }); + unregister?.(); + return typeof unregister; + })(), + })); + + expect(runtimeShape.hasRuntime).toBe(true); + expect(runtimeShape.hasTools).toBe(true); + expect(runtimeShape.hasSelectionMode).toBe('function'); + expect(runtimeShape.hasGetContext).toBe('function'); + expect(runtimeShape.hasCopyContext).toBe('function'); + expect(runtimeShape.hasRegisterTuningModalExtension).toBe('function'); + expect(runtimeShape.tuningSurfaceClassName).toBe( + 'next-playground-tuning-surface', + ); + expect(runtimeShape.tuningPanelClassName).toBe( + 'next-playground-tuning-panel', + ); + expect(runtimeShape.unregisterType).toBe('function'); +}); + +test('next playground MCP tools return expected outcomes', async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const { client, transport } = await createMcpClient(); + try { + const htmlMatchesRaw = await client.callTool({ + name: 'get-html-elements', + arguments: { + queries: ['next-copy-target'], + maxMatches: 3, + }, + }); + const htmlMatches = parseToolResponse(htmlMatchesRaw); + expect(htmlMatches.success).toBe(true); + expect(htmlMatches.count).toBeGreaterThan(0); + expect(htmlMatches.matches[0].selector).toBe('#next-copy-target'); + + const reactSourceRaw = await client.callTool({ + name: 'get-react-source-code', + arguments: { + queries: ['next-copy-target'], + maxMatches: 3, + includeSourceSnippets: true, + }, + }); + const reactSource = parseToolResponse(reactSourceRaw); + expect(reactSource.success).toBe(true); + expect(reactSource.chosenMatch.selector).toBe('#next-copy-target'); + expect(reactSource.context).toBeTruthy(); + + const noMatchRaw = await client.callTool({ + name: 'get-react-source-code', + arguments: { + queries: ['text-not-present-in-next-playground'], + }, + }); + const noMatch = parseToolResponse(noMatchRaw); + expect(noMatch.success).toBe(false); + expect(noMatch.reason).toContain('No element matched'); + expect(noMatch.context).toBeNull(); + + const enableSelectionRaw = await client.callTool({ + name: 'set-selection-mode', + arguments: { enabled: true }, + }); + const enableSelection = parseToolResponse(enableSelectionRaw); + expect(enableSelection.success).toBe(true); + expect(enableSelection.enabled).toBe(true); + + await page.locator('#next-copy-target').click(); + await page.waitForFunction(() => + window.__AGENTIC_REACT__?.getLastSelectionContext(), + ); + const contextRaw = await client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 3, + maxFiles: 3, + }, + }); + const context = parseToolResponse(contextRaw); + expect(context.success).toBe(true); + expect(context.context.selector).toBe('#next-copy-target'); + + const copyRaw = await client.callTool({ + name: 'copy-last-selection-context', + arguments: { format: 'json' }, + }); + const copyResponse = parseToolResponse(copyRaw); + expect(copyResponse.success).toBe(true); + expect(copyResponse.copied).toBe(true); + } finally { + await transport.close(); + } +}); diff --git a/playground/agentic-react-next-playground/next.config.mjs b/playground/agentic-react-next-playground/next.config.mjs new file mode 100644 index 0000000..33a7fd8 --- /dev/null +++ b/playground/agentic-react-next-playground/next.config.mjs @@ -0,0 +1,45 @@ +import withAgenticReactNext from '@agentic-react/next'; + +const bridgePort = Number(process.env.AGENTIC_REACT_NEXT_BRIDGE_PORT || 51426); + +const nextConfig = { + allowedDevOrigins: ['127.0.0.1'], + reactStrictMode: true, +}; + +export default withAgenticReactNext(nextConfig, { + server: { + port: bridgePort, + }, + toolkit: { + tuningModal: { + classNames: { + surface: 'next-playground-tuning-surface', + panel: 'next-playground-tuning-panel', + control: 'next-playground-tuning-control', + }, + tokens: { + panelRadius: '12px', + controlRadius: '9px', + primaryButtonBackground: '#7c2d12', + primaryButtonColor: '#ffffff', + panelShadow: '0 24px 72px rgba(124, 45, 18, 0.2)', + }, + styles: { + surface: { + filter: 'drop-shadow(0 18px 40px rgba(124, 45, 18, 0.14))', + }, + panel: { + border: '1px solid rgba(124, 45, 18, 0.24)', + }, + targetTag: { + background: '#fff7ed', + color: '#7c2d12', + }, + sectionTitle: { + color: '#7c2d12', + }, + }, + }, + }, +}); diff --git a/playground/agentic-react-next-playground/package.json b/playground/agentic-react-next-playground/package.json new file mode 100644 index 0000000..d2e2bc7 --- /dev/null +++ b/playground/agentic-react-next-playground/package.json @@ -0,0 +1,21 @@ +{ + "name": "agentic-react-next-playground", + "private": true, + "type": "module", + "scripts": { + "start": "next dev -H 127.0.0.1 -p 51424", + "build": "next build", + "test:e2e": "pnpm -C ../.. run build && env -u NO_COLOR playwright test" + }, + "dependencies": { + "@phosphor-icons/react": "^2.1.10", + "antd": "^6.3.7", + "next": "15.5.18", + "react": "19.1.1", + "react-dom": "19.1.1", + "@agentic-react/next": "workspace:*" + }, + "devDependencies": { + "@playwright/test": "^1.59.1" + } +} diff --git a/playground/agentic-react-next-playground/playwright.config.js b/playground/agentic-react-next-playground/playwright.config.js new file mode 100644 index 0000000..f80955a --- /dev/null +++ b/playground/agentic-react-next-playground/playwright.config.js @@ -0,0 +1,128 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from '@playwright/test'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const host = '127.0.0.1'; +const portStateKey = Buffer.from(__dirname).toString('base64url'); +const portStatePath = path.join( + os.tmpdir(), + `agentic-react-next-playground-${portStateKey}.json`, +); +const portLockPath = path.join( + os.tmpdir(), + `agentic-react-next-playground-${portStateKey}.lock`, +); + +const canListen = (port) => + new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); + +const findAvailablePort = async (preferredPort) => { + for (let port = preferredPort; port < preferredPort + 1000; port += 1) { + if (await canListen(port)) { + return port; + } + } + + throw new Error( + `Could not find an available Next bridge port starting at ${preferredPort}.`, + ); +}; + +const readPortState = () => { + try { + const state = JSON.parse(fs.readFileSync(portStatePath, 'utf8')); + if (Date.now() - state.createdAt < 10 * 60 * 1000) { + return Number(state.port); + } + } catch (_error) { + return null; + } + + return null; +}; + +const writePortState = (port) => { + fs.mkdirSync(path.dirname(portStatePath), { recursive: true }); + fs.writeFileSync( + portStatePath, + JSON.stringify({ createdAt: Date.now(), port }, null, 2), + ); +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const waitForPortState = async () => { + for (let attempt = 0; attempt < 100; attempt += 1) { + const port = readPortState(); + if (port) { + return port; + } + await sleep(100); + } + + throw new Error('Timed out waiting for Next bridge port allocation.'); +}; + +const allocatePlaywrightBridgePort = async () => { + const existingPort = readPortState(); + if (existingPort) { + return existingPort; + } + + fs.mkdirSync(path.dirname(portStatePath), { recursive: true }); + let lockHandle; + try { + lockHandle = fs.openSync(portLockPath, 'wx'); + } catch (_error) { + return await waitForPortState(); + } + + try { + const port = await findAvailablePort( + Number(process.env.AGENTIC_REACT_NEXT_BRIDGE_PORT || 51426), + ); + writePortState(port); + return port; + } finally { + fs.closeSync(lockHandle); + fs.rmSync(portLockPath, { force: true }); + } +}; + +const bridgePort = await allocatePlaywrightBridgePort(); +process.env.AGENTIC_REACT_NEXT_BRIDGE_PORT = String(bridgePort); + +export default defineConfig({ + testDir: './e2e', + timeout: 30000, + expect: { + timeout: 5000, + }, + use: { + baseURL: 'http://127.0.0.1:51424', + permissions: ['clipboard-read', 'clipboard-write'], + trace: 'on-first-retry', + }, + webServer: { + command: 'pnpm start', + cwd: __dirname, + env: { + ...process.env, + AGENTIC_REACT_NEXT_BRIDGE_PORT: String(bridgePort), + }, + url: 'http://127.0.0.1:51424', + timeout: 180000, + reuseExistingServer: false, + }, +}); diff --git a/playground/agentic-react-next-playground/public/generated/runtime-command-center.png b/playground/agentic-react-next-playground/public/generated/runtime-command-center.png new file mode 100644 index 0000000..bf1ac60 Binary files /dev/null and b/playground/agentic-react-next-playground/public/generated/runtime-command-center.png differ diff --git a/playground/agentic-react-nx-module-federation-playground/.agentic-react-webpack/client-entry.mjs b/playground/agentic-react-nx-module-federation-playground/.agentic-react-webpack/client-entry.mjs new file mode 100644 index 0000000..203f364 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/.agentic-react-webpack/client-entry.mjs @@ -0,0 +1,47 @@ + +import { + __AGENTIC_REACT_BRIDGE_URL__, + __AGENTIC_REACT_CONFIG__, +} from "../../../packages/core/dist/shared/const.js"; +import { BRIDGE_WS_PATH } from "../../../packages/core/dist/shared/protocol.js"; + +if (typeof window !== 'undefined') { + const existingAgenticReactConfig = window[__AGENTIC_REACT_CONFIG__] || {}; + window[__AGENTIC_REACT_CONFIG__] = { + ...existingAgenticReactConfig, + sourceRoot: existingAgenticReactConfig.sourceRoot || "/Users/jazelly/Desktop/github/my-proj/vite-react-mcp/playground/agentic-react-nx-module-federation-playground", + toolkit: { + ...(existingAgenticReactConfig.toolkit || {}), + ...{"tuningModal":{"classNames":{"surface":"nx-shell-tuning-surface","panel":"nx-shell-tuning-panel","control":"nx-shell-tuning-control"},"tokens":{"panelRadius":"12px","controlRadius":"9px","primaryButtonBackground":"#4338ca","primaryButtonColor":"#ffffff","panelShadow":"0 24px 72px rgba(67, 56, 202, 0.22)"},"styles":{"surface":{"filter":"drop-shadow(0 18px 40px rgba(67, 56, 202, 0.15))"},"panel":{"border":"1px solid rgba(67, 56, 202, 0.24)"},"targetTag":{"background":"#eef2ff","color":"#4338ca"},"sectionTitle":{"color":"#4338ca"}}}}, + }, + }; + + if (!window[__AGENTIC_REACT_BRIDGE_URL__]) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + window[__AGENTIC_REACT_BRIDGE_URL__] = + `${protocol}//${window.location.host}${BRIDGE_WS_PATH}`; + } +} + +void import("../../../packages/core/dist/overlay.js"); + + + const registerTool = (name, handler) => { + const tryRegister = (attempt = 0) => { + const registry = window.__AGENTIC_REACT_TOOLS__; + if (registry?.registerCustomTool) { + registry.registerCustomTool(name, handler); + return; + } + if (attempt >= 40) { + console.warn('[agentic-react] Custom tool registration timed out for', name); + return; + } + setTimeout(() => tryRegister(attempt + 1), 50); + }; + + tryRegister(); + }; + + + diff --git a/playground/agentic-react-nx-module-federation-playground/.eslintrc.json b/playground/agentic-react-nx-module-federation-playground/.eslintrc.json new file mode 100644 index 0000000..84232aa --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "root": true, + "ignorePatterns": ["**/*"], + "plugins": ["@nrwl/nx"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/.babelrc b/playground/agentic-react-nx-module-federation-playground/apps/catalog/.babelrc new file mode 100644 index 0000000..3c53b4f --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/.babelrc @@ -0,0 +1,17 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": ">0.25%, not dead" + } + ], + [ + "@babel/preset-react", + { + "runtime": "automatic" + } + ], + "@babel/preset-typescript" + ] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/module-federation.config.js b/playground/agentic-react-nx-module-federation-playground/apps/catalog/module-federation.config.js new file mode 100644 index 0000000..ff4c580 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/module-federation.config.js @@ -0,0 +1,17 @@ +module.exports = { + name: 'catalog', + exposes: { + './Routes': './src/app/Routes.tsx', + }, + shared: (libraryName, defaultConfig) => { + if (['react', 'react-dom', 'react-router-dom'].includes(libraryName)) { + return { + ...defaultConfig, + singleton: true, + strictVersion: true, + }; + } + + return defaultConfig; + }, +}; diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/project.json b/playground/agentic-react-nx-module-federation-playground/apps/catalog/project.json new file mode 100644 index 0000000..b958518 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/project.json @@ -0,0 +1,66 @@ +{ + "name": "catalog", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/catalog/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nrwl/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "compiler": "babel", + "outputPath": "dist/apps/catalog", + "index": "apps/catalog/src/index.html", + "baseHref": "/", + "main": "apps/catalog/src/main.ts", + "polyfills": "apps/catalog/src/polyfills.ts", + "tsConfig": "apps/catalog/tsconfig.app.json", + "assets": ["apps/catalog/src/assets"], + "styles": ["apps/catalog/src/styles.css"], + "scripts": [], + "webpackConfig": "apps/catalog/webpack.config.js" + }, + "configurations": { + "development": { + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + "vendorChunk": true + }, + "production": { + "extractLicenses": true, + "optimization": true, + "sourceMap": false, + "vendorChunk": false + } + } + }, + "serve": { + "executor": "@nrwl/webpack:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "catalog:build", + "host": "127.0.0.1", + "port": 4201 + }, + "configurations": { + "development": { + "buildTarget": "catalog:build:development" + }, + "production": { + "buildTarget": "catalog:build:production" + } + } + }, + "dev": { + "executor": "@nrwl/webpack:dev-server", + "options": { + "buildTarget": "catalog:build:development", + "host": "127.0.0.1", + "port": 4201 + } + } + }, + "tags": ["scope:remote", "type:app"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/Routes.tsx b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/Routes.tsx new file mode 100644 index 0000000..718f856 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/Routes.tsx @@ -0,0 +1,16 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { hearthHubTheme } from '@mfplayground/hearthhub-ui'; +import { Route, Routes } from 'react-router-dom'; +import { CatalogIndex } from './pages/CatalogIndex'; +import { ProductDetail } from './pages/ProductDetail'; + +export default function CatalogRoutes() { + return ( + + + } /> + } /> + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/components/CatalogSummaryCards.tsx b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/components/CatalogSummaryCards.tsx new file mode 100644 index 0000000..7fcd255 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/components/CatalogSummaryCards.tsx @@ -0,0 +1,29 @@ +import { SimpleGrid, Text } from '@chakra-ui/react'; +import { ServerStackIcon, ShieldCheckIcon } from '@heroicons/react/24/outline'; +import { + MetricCard, + SectionCard, + servicePlans, +} from '@mfplayground/hearthhub-ui'; + +export function CatalogSummaryCards() { + return ( + + + catalog/Routes + service marketplace module + + + + + Chakra tables, plan links, badges, and progress rows + + source aware + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/components/MarketplaceQualityBar.tsx b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/components/MarketplaceQualityBar.tsx new file mode 100644 index 0000000..2773d48 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/components/MarketplaceQualityBar.tsx @@ -0,0 +1,24 @@ +import { Divider, List, ListIcon, ListItem, Text } from '@chakra-ui/react'; +import { CheckCircleIcon, SparklesIcon } from '@heroicons/react/24/outline'; +import { SectionCard, vendorSignals } from '@mfplayground/hearthhub-ui'; + +export function MarketplaceQualityBar() { + return ( + + + {vendorSignals.map((item) => ( + + + {item} + + ))} + + + + The catalog remote can evolve pricing, taxonomy, and service + merchandising independently while the shell preserves a stable customer + navigation model. + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/components/ServicePlanTable.tsx b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/components/ServicePlanTable.tsx new file mode 100644 index 0000000..f3b8ac9 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/components/ServicePlanTable.tsx @@ -0,0 +1,73 @@ +import { + Badge, + Button, + Icon, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from '@chakra-ui/react'; +import { + ArrowTopRightOnSquareIcon, + CubeIcon, +} from '@heroicons/react/24/outline'; +import { AppIcon, SectionCard, servicePlans } from '@mfplayground/hearthhub-ui'; +import { Link } from 'react-router-dom'; + +export function ServicePlanTable() { + return ( + +
+ + + + + + + + + + + {servicePlans.map((plan) => ( + + + + + + + + ))} + +
PlanOwnerPriceStatusOpen
+ + + {plan.technicalName} + + {plan.owner}{plan.price} + + {plan.status} + + + +
+ + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/pages/CatalogIndex.tsx b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/pages/CatalogIndex.tsx new file mode 100644 index 0000000..3d512f6 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/pages/CatalogIndex.tsx @@ -0,0 +1,31 @@ +import { Grid, Stack } from '@chakra-ui/react'; +import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline'; +import { RemotePageHeader } from '@mfplayground/hearthhub-ui'; +import { LegacyRuntimeBadge } from '@mfplayground/legacy-react-widget'; +import { CatalogSummaryCards } from '../components/CatalogSummaryCards'; +import { MarketplaceQualityBar } from '../components/MarketplaceQualityBar'; +import { ServicePlanTable } from '../components/ServicePlanTable'; + +export function CatalogIndex() { + return ( + + } + icon={WrenchScrewdriverIcon} + label="Catalog remote" + title="Federated product routes." + > + The services marketplace team owns the plan inventory, vendor + availability, enrollment copy, and detail pages that help a household + choose practical coverage before a repair becomes urgent. + + + + + + + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/pages/ProductDetail.tsx b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/pages/ProductDetail.tsx new file mode 100644 index 0000000..9698f06 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/app/pages/ProductDetail.tsx @@ -0,0 +1,76 @@ +import { + Badge, + Button, + Card, + CardBody, + Grid, + HStack, + Heading, + Icon, + Progress, + Text, +} from '@chakra-ui/react'; +import { ArrowLeftIcon, CurrencyDollarIcon } from '@heroicons/react/24/outline'; +import { SectionCard, servicePlans } from '@mfplayground/hearthhub-ui'; +import { Link, useParams } from 'react-router-dom'; + +export function ProductDetail() { + const params = useParams(); + const selectedPlan = + servicePlans.find((plan) => plan.id === params.productId) ?? + servicePlans[0]; + + return ( + + + + + Remote detail + + + {selectedPlan.name} + + + {selectedPlan.summary} + + + {selectedPlan.status} + {selectedPlan.price} + + + + + + + + {selectedPlan.technicalName} + {selectedPlan.readiness}% + + + + This route is intentionally owned by the catalog remote so runtime MCP + tools can inspect a real customer workflow across the host boundary + while preserving service ownership, plan copy, and source-context + routing. + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/assets/.gitkeep b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/assets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/assets/.gitkeep @@ -0,0 +1 @@ + diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/bootstrap.tsx b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/bootstrap.tsx new file mode 100644 index 0000000..3d2a462 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/bootstrap.tsx @@ -0,0 +1,16 @@ +import { createRoot } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import Routes from './app/Routes'; +import './styles.css'; + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Missing #root element for catalog remote'); +} + +createRoot(rootElement).render( + + + , +); diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/index.html b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/index.html new file mode 100644 index 0000000..c6b5fa7 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/index.html @@ -0,0 +1,12 @@ + + + + + Nx Catalog Remote + + + + +
+ + diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/main.ts b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/main.ts new file mode 100644 index 0000000..b93c7a0 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/main.ts @@ -0,0 +1 @@ +import('./bootstrap'); diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/polyfills.ts b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/polyfills.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/polyfills.ts @@ -0,0 +1 @@ +export {}; diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/styles.css b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/styles.css new file mode 100644 index 0000000..f4f93b2 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/src/styles.css @@ -0,0 +1,127 @@ +:root { + color: #172033; + background: #f7f8fb; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; +} + +body { + margin: 0; + background: #f7f8fb; +} + +.remote-panel { + margin: 24px; + padding: 32px; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #ffffff; +} + +.remote-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} + +.section-label { + margin: 0 0 10px; + color: #2563eb; + font-size: 13px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +h2 { + margin: 0; + color: #0f172a; + font-size: clamp(30px, 4vw, 48px); + line-height: 1; +} + +.product-table { + width: 100%; + margin-top: 28px; + overflow: hidden; + border: 1px solid #dbe3ef; + border-radius: 8px; + border-collapse: separate; + border-spacing: 0; +} + +.product-table caption { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} + +.product-row th, +.product-row td { + min-height: 56px; + border-top: 1px solid #e5edf6; + color: #334155; + padding: 0 18px; + text-align: left; + vertical-align: middle; +} + +.product-row:first-child th { + border-top: 0; +} + +.product-row.heading { + background: #f8fafc; + color: #64748b; + font-size: 13px; + font-weight: 800; + text-transform: uppercase; +} + +.product-row:not(.heading):hover td { + background: #eff6ff; +} + +.product-row a { + color: #1d4ed8; + font-weight: 800; + text-decoration: none; +} + +.remote-copy { + max-width: 680px; + color: #475569; + font-size: 18px; + line-height: 1.7; +} + +.back-link { + color: #1d4ed8; + font-weight: 800; +} + +@media (max-width: 720px) { + .remote-panel { + margin: 0; + padding: 28px; + } + + .remote-header { + flex-direction: column; + } + + .product-row { + min-height: 84px; + } + + .product-row th, + .product-row td { + display: block; + min-height: 0; + padding: 10px 18px; + } +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/tsconfig.app.json b/playground/agentic-react-nx-module-federation-playground/apps/catalog/tsconfig.app.json new file mode 100644 index 0000000..4588365 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts", "src/polyfills.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/catalog/webpack.config.js b/playground/agentic-react-nx-module-federation-playground/apps/catalog/webpack.config.js new file mode 100644 index 0000000..eea1bba --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/catalog/webpack.config.js @@ -0,0 +1,74 @@ +const path = require('node:path'); +const { composePlugins, withNx } = require('@nrwl/webpack'); +const { withReact } = require('@nrwl/react'); +const { merge } = require('webpack-merge'); +const withModuleFederation = require('../../module-federation'); +const { replaceDefinePlugin } = require('../../webpack.config.base'); +const moduleFederationConfig = require('./module-federation.config'); +const workspaceRoot = path.resolve(__dirname, '../..'); +const toolkit = { + tuningModal: { + classNames: { + surface: 'nx-catalog-tuning-surface', + panel: 'nx-catalog-tuning-panel', + control: 'nx-catalog-tuning-control', + }, + tokens: { + panelRadius: '12px', + controlRadius: '9px', + primaryButtonBackground: '#be123c', + primaryButtonColor: '#ffffff', + panelShadow: '0 24px 72px rgba(190, 18, 60, 0.2)', + }, + styles: { + surface: { + filter: 'drop-shadow(0 18px 40px rgba(190, 18, 60, 0.14))', + }, + panel: { + border: '1px solid rgba(190, 18, 60, 0.24)', + }, + targetTag: { + background: '#fff1f2', + color: '#be123c', + }, + sectionTitle: { + color: '#be123c', + }, + }, + }, +}; + +module.exports = composePlugins( + withNx(), + withReact(), + async (config, nxContext) => { + const federatedModules = await withModuleFederation( + moduleFederationConfig, + ); + const { default: withAgenticReactWebpack } = await import('@agentic-react/webpack'); + const mergedConfig = replaceDefinePlugin( + merge(federatedModules(config, nxContext), { + devServer: { + historyApiFallback: true, + client: { + overlay: false, + }, + }, + }), + ); + + return withAgenticReactWebpack( + mergedConfig, + { + mode: + nxContext?.context?.configurationName === 'production' + ? 'production' + : 'development', + }, + { + rootDir: workspaceRoot, + toolkit, + }, + ); + }, +); diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/.babelrc b/playground/agentic-react-nx-module-federation-playground/apps/profile/.babelrc new file mode 100644 index 0000000..3c53b4f --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/.babelrc @@ -0,0 +1,17 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": ">0.25%, not dead" + } + ], + [ + "@babel/preset-react", + { + "runtime": "automatic" + } + ], + "@babel/preset-typescript" + ] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/module-federation.config.js b/playground/agentic-react-nx-module-federation-playground/apps/profile/module-federation.config.js new file mode 100644 index 0000000..c8ea3f9 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/module-federation.config.js @@ -0,0 +1,17 @@ +module.exports = { + name: 'profile', + exposes: { + './Routes': './src/app/Routes.tsx', + }, + shared: (libraryName, defaultConfig) => { + if (['react', 'react-dom', 'react-router-dom'].includes(libraryName)) { + return { + ...defaultConfig, + singleton: true, + strictVersion: true, + }; + } + + return defaultConfig; + }, +}; diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/project.json b/playground/agentic-react-nx-module-federation-playground/apps/profile/project.json new file mode 100644 index 0000000..4b1e866 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/project.json @@ -0,0 +1,66 @@ +{ + "name": "profile", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/profile/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nrwl/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "compiler": "babel", + "outputPath": "dist/apps/profile", + "index": "apps/profile/src/index.html", + "baseHref": "/", + "main": "apps/profile/src/main.ts", + "polyfills": "apps/profile/src/polyfills.ts", + "tsConfig": "apps/profile/tsconfig.app.json", + "assets": ["apps/profile/src/assets"], + "styles": ["apps/profile/src/styles.css"], + "scripts": [], + "webpackConfig": "apps/profile/webpack.config.js" + }, + "configurations": { + "development": { + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + "vendorChunk": true + }, + "production": { + "extractLicenses": true, + "optimization": true, + "sourceMap": false, + "vendorChunk": false + } + } + }, + "serve": { + "executor": "@nrwl/webpack:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "profile:build", + "host": "127.0.0.1", + "port": 4202 + }, + "configurations": { + "development": { + "buildTarget": "profile:build:development" + }, + "production": { + "buildTarget": "profile:build:production" + } + } + }, + "dev": { + "executor": "@nrwl/webpack:dev-server", + "options": { + "buildTarget": "profile:build:development", + "host": "127.0.0.1", + "port": 4202 + } + } + }, + "tags": ["scope:remote", "type:app"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/Routes.tsx b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/Routes.tsx new file mode 100644 index 0000000..00e6f4f --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/Routes.tsx @@ -0,0 +1,14 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { hearthHubTheme } from '@mfplayground/hearthhub-ui'; +import { Route, Routes } from 'react-router-dom'; +import { ProfileIndex } from './pages/ProfileIndex'; + +export default function ProfileRoutes() { + return ( + + + } /> + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/ExternalDependencySelectionProbe.tsx b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/ExternalDependencySelectionProbe.tsx new file mode 100644 index 0000000..cbb2d3f --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/ExternalDependencySelectionProbe.tsx @@ -0,0 +1,44 @@ +import { + Button, + Card, + CardBody, + HStack, + Heading, + Text, +} from '@chakra-ui/react'; +import { BoltIcon } from '@heroicons/react/24/outline'; +import { AppIcon } from '@mfplayground/hearthhub-ui'; + +export function ExternalDependencySelectionProbe() { + return ( + + + + + +
+ External dependency selection + + This local profile component intentionally renders a Chakra UI + button so selector tests can attribute a package component back + to the project file that uses it. + +
+
+ +
+
+
+ ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/HouseholdContacts.tsx b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/HouseholdContacts.tsx new file mode 100644 index 0000000..35e3cec --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/HouseholdContacts.tsx @@ -0,0 +1,33 @@ +import { Card, CardBody, HStack, Heading, Stack } from '@chakra-ui/react'; +import { UsersIcon } from '@heroicons/react/24/outline'; +import { IconBadge, householdMembers } from '@mfplayground/hearthhub-ui'; +import { ProfileMemberCard } from './ProfileMemberCard'; + +export function HouseholdContacts() { + return ( + + + + + Household contacts + + + {householdMembers.map((member) => ( + + ))} + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/HouseholdProfileSummary.tsx b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/HouseholdProfileSummary.tsx new file mode 100644 index 0000000..0d0050f --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/HouseholdProfileSummary.tsx @@ -0,0 +1,81 @@ +import { + Card, + CardBody, + Divider, + HStack, + Heading, + List, + ListIcon, + ListItem, + Tag, + TagLabel, + Text, + VStack, +} from '@chakra-ui/react'; +import { + CheckCircleIcon, + HomeModernIcon, + KeyIcon, +} from '@heroicons/react/24/outline'; +import { IconBadge, householdPreferences } from '@mfplayground/hearthhub-ui'; + +export function HouseholdProfileSummary() { + return ( + + + + + + + Household profile summary + + + + Two-story family home, home office on the ground floor, nursery + upstairs, mixed indoor/outdoor access, and recurring seasonal care + through the Family Plus plan. + + + + {[ + 'Side gate access', + 'Pet-aware visits', + 'Quiet hours', + 'Text first', + ].map((tag) => ( + + {tag} + + ))} + + + + + + + + + Access and care preferences + + + {householdPreferences.map((preference) => ( + + + {preference} + + ))} + + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/PreferencePanel.tsx b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/PreferencePanel.tsx new file mode 100644 index 0000000..bd2b891 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/PreferencePanel.tsx @@ -0,0 +1,32 @@ +import { Card, CardBody, HStack, Heading, Text } from '@chakra-ui/react'; +import { AppIcon, type IconComponent } from '@mfplayground/hearthhub-ui'; +import type { ReactNode } from 'react'; + +export function PreferencePanel({ + children, + icon, + title, +}: { + children: ReactNode; + icon: IconComponent; + title: string; +}) { + return ( + + + + + {title} + + + {children} + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/ProfileMemberCard.tsx b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/ProfileMemberCard.tsx new file mode 100644 index 0000000..374f3f2 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/components/ProfileMemberCard.tsx @@ -0,0 +1,54 @@ +import { Avatar, Box, CardBody, HStack, Heading, Text } from '@chakra-ui/react'; +import { SparklesIcon } from '@heroicons/react/24/outline'; +import { IconBadge } from '@mfplayground/hearthhub-ui'; + +export function ProfileMemberCard({ + focus, + name, + note, + role, +}: { + focus: string; + name: string; + note: string; + role: string; +}) { + const memberId = name.toLowerCase().replace(/\s+/g, '-'); + const selectionId = `profile-member-${memberId}`; + + return ( +
+ + + + + + + {name} + + + {role} + + + {focus} + + + {note} + + + + + + +
+ ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/pages/ProfileIndex.tsx b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/pages/ProfileIndex.tsx new file mode 100644 index 0000000..abe6d91 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/app/pages/ProfileIndex.tsx @@ -0,0 +1,81 @@ +import { SimpleGrid, Stack } from '@chakra-ui/react'; +import { + AcademicCapIcon, + BoltIcon, + MapPinIcon, + ShieldCheckIcon, + UserCircleIcon, +} from '@heroicons/react/24/outline'; +import { + MetricCard, + RemotePageHeader, + householdMembers, +} from '@mfplayground/hearthhub-ui'; +import { ExternalDependencySelectionProbe } from '../components/ExternalDependencySelectionProbe'; +import { HouseholdContacts } from '../components/HouseholdContacts'; +import { HouseholdProfileSummary } from '../components/HouseholdProfileSummary'; +import { PreferencePanel } from '../components/PreferencePanel'; + +export function ProfileIndex() { + return ( + + + The profile remote owns the household context that makes every service + visit feel prepared: authorized contacts, access instructions, care + preferences, pet notes, and inspection-safe member cards for MCP + selection across a remote boundary. + + + + + + + + + + + + + + + + + + The Sam Rivera card keeps a stable id for cross-remote MCP selection + while still being wrapped by production-like Chakra layout, avatar, + typography, and Heroicon components. + + + Nested Chakra cards, tags, stats, lists, avatars, and icon wrappers + remain visible to component tree, selection context, highlight, and + source-code tools. + + + Household notes are specific enough for service coordination but + scoped to operational needs rather than exposing unnecessary personal + detail. + + + The profile remote can change household data capture without requiring + the services catalog team to redeploy its marketplace routes. + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/assets/.gitkeep b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/assets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/assets/.gitkeep @@ -0,0 +1 @@ + diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/bootstrap.tsx b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/bootstrap.tsx new file mode 100644 index 0000000..6f31849 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/bootstrap.tsx @@ -0,0 +1,16 @@ +import { createRoot } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import Routes from './app/Routes'; +import './styles.css'; + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Missing #root element for profile remote'); +} + +createRoot(rootElement).render( + + + , +); diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/index.html b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/index.html new file mode 100644 index 0000000..a537381 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/index.html @@ -0,0 +1,12 @@ + + + + + Nx Profile Remote + + + + +
+ + diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/main.ts b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/main.ts new file mode 100644 index 0000000..b93c7a0 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/main.ts @@ -0,0 +1 @@ +import('./bootstrap'); diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/polyfills.ts b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/polyfills.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/polyfills.ts @@ -0,0 +1 @@ +export {}; diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/src/styles.css b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/styles.css new file mode 100644 index 0000000..2352566 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/src/styles.css @@ -0,0 +1,60 @@ +:root { + color: #172033; + background: #f7f8fb; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; +} + +body { + margin: 0; + background: #f7f8fb; +} + +.profile-panel { + margin: 24px; + padding: 32px; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #ffffff; +} + +.section-label { + margin: 0 0 10px; + color: #2563eb; + font-size: 13px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +h2 { + margin: 0; + color: #0f172a; + font-size: clamp(30px, 4vw, 48px); + line-height: 1; +} + +.profile-list { + display: grid; + gap: 12px; + margin-top: 28px; +} + +.profile-list article { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 64px; + border: 1px solid #dbe3ef; + border-radius: 8px; + padding: 0 18px; +} + +.profile-list strong { + color: #111827; +} + +.profile-list span { + color: #475569; +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/tsconfig.app.json b/playground/agentic-react-nx-module-federation-playground/apps/profile/tsconfig.app.json new file mode 100644 index 0000000..4588365 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts", "src/polyfills.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/profile/webpack.config.js b/playground/agentic-react-nx-module-federation-playground/apps/profile/webpack.config.js new file mode 100644 index 0000000..5366f09 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/profile/webpack.config.js @@ -0,0 +1,74 @@ +const path = require('node:path'); +const { composePlugins, withNx } = require('@nrwl/webpack'); +const { withReact } = require('@nrwl/react'); +const { merge } = require('webpack-merge'); +const withModuleFederation = require('../../module-federation'); +const { replaceDefinePlugin } = require('../../webpack.config.base'); +const moduleFederationConfig = require('./module-federation.config'); +const workspaceRoot = path.resolve(__dirname, '../..'); +const toolkit = { + tuningModal: { + classNames: { + surface: 'nx-profile-tuning-surface', + panel: 'nx-profile-tuning-panel', + control: 'nx-profile-tuning-control', + }, + tokens: { + panelRadius: '12px', + controlRadius: '9px', + primaryButtonBackground: '#0f766e', + primaryButtonColor: '#ffffff', + panelShadow: '0 24px 72px rgba(15, 118, 110, 0.22)', + }, + styles: { + surface: { + filter: 'drop-shadow(0 18px 40px rgba(15, 118, 110, 0.15))', + }, + panel: { + border: '1px solid rgba(15, 118, 110, 0.24)', + }, + targetTag: { + background: '#ecfeff', + color: '#0f766e', + }, + sectionTitle: { + color: '#0f766e', + }, + }, + }, +}; + +module.exports = composePlugins( + withNx(), + withReact(), + async (config, nxContext) => { + const federatedModules = await withModuleFederation( + moduleFederationConfig, + ); + const { default: withAgenticReactWebpack } = await import('@agentic-react/webpack'); + const mergedConfig = replaceDefinePlugin( + merge(federatedModules(config, nxContext), { + devServer: { + historyApiFallback: true, + client: { + overlay: false, + }, + }, + }), + ); + + return withAgenticReactWebpack( + mergedConfig, + { + mode: + nxContext?.context?.configurationName === 'production' + ? 'production' + : 'development', + }, + { + rootDir: workspaceRoot, + toolkit, + }, + ); + }, +); diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/.babelrc b/playground/agentic-react-nx-module-federation-playground/apps/shell/.babelrc new file mode 100644 index 0000000..3c53b4f --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/.babelrc @@ -0,0 +1,17 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": ">0.25%, not dead" + } + ], + [ + "@babel/preset-react", + { + "runtime": "automatic" + } + ], + "@babel/preset-typescript" + ] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/module-federation.config.js b/playground/agentic-react-nx-module-federation-playground/apps/shell/module-federation.config.js new file mode 100644 index 0000000..fda5094 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/module-federation.config.js @@ -0,0 +1,15 @@ +module.exports = { + name: 'shell', + remotes: ['catalog', 'profile'], + shared: (libraryName, defaultConfig) => { + if (['react', 'react-dom', 'react-router-dom'].includes(libraryName)) { + return { + ...defaultConfig, + singleton: true, + strictVersion: true, + }; + } + + return defaultConfig; + }, +}; diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/project.json b/playground/agentic-react-nx-module-federation-playground/apps/shell/project.json new file mode 100644 index 0000000..863d529 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/project.json @@ -0,0 +1,67 @@ +{ + "name": "shell", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/shell/src", + "projectType": "application", + "implicitDependencies": ["catalog", "profile"], + "targets": { + "build": { + "executor": "@nrwl/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "compiler": "babel", + "outputPath": "dist/apps/shell", + "index": "apps/shell/src/index.html", + "baseHref": "/", + "main": "apps/shell/src/main.ts", + "polyfills": "apps/shell/src/polyfills.ts", + "tsConfig": "apps/shell/tsconfig.app.json", + "assets": ["apps/shell/src/assets"], + "styles": ["apps/shell/src/styles.css"], + "scripts": [], + "webpackConfig": "apps/shell/webpack.config.js" + }, + "configurations": { + "development": { + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + "vendorChunk": true + }, + "production": { + "extractLicenses": true, + "optimization": true, + "sourceMap": false, + "vendorChunk": false + } + } + }, + "serve": { + "executor": "@nrwl/webpack:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "shell:build", + "host": "127.0.0.1", + "port": 4200 + }, + "configurations": { + "development": { + "buildTarget": "shell:build:development" + }, + "production": { + "buildTarget": "shell:build:production" + } + } + }, + "dev": { + "executor": "@nrwl/webpack:dev-server", + "options": { + "buildTarget": "shell:build:development", + "host": "127.0.0.1", + "port": 4200 + } + } + }, + "tags": ["scope:host", "type:app"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/App.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/App.tsx new file mode 100644 index 0000000..28fb2ac --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/App.tsx @@ -0,0 +1,59 @@ +import { ChakraProvider, Text } from '@chakra-ui/react'; +import { PuzzlePieceIcon, TruckIcon } from '@heroicons/react/24/outline'; +import { SectionCard, hearthHubTheme } from '@mfplayground/hearthhub-ui'; +import { Suspense, lazy } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { AppFrame } from './components/AppFrame'; +import { Diagnostics } from './pages/Diagnostics'; +import { ShellHome } from './pages/ShellHome'; + +const CatalogRoutes = lazy(() => import('catalog/Routes')); +const ProfileRoutes = lazy(() => import('profile/Routes')); + +export function App() { + return ( + + + + } /> + + Loading services catalog... + + } + > + + + } + /> + + Loading household profile... + + } + > + + + } + /> + } /> + } /> + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/AppFrame.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/AppFrame.tsx new file mode 100644 index 0000000..c352a70 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/AppFrame.tsx @@ -0,0 +1,287 @@ +import { + Avatar, + Box, + Button, + Circle, + Divider, + Flex, + HStack, + Heading, + Icon, + Input, + InputGroup, + InputLeftElement, + Text, +} from '@chakra-ui/react'; +import { + BellIcon, + ChatBubbleLeftRightIcon, + ChevronDownIcon, + HomeModernIcon, + LifebuoyIcon, + MagnifyingGlassIcon, + ShieldCheckIcon, +} from '@heroicons/react/24/outline'; +import { + AppIcon, + type IconComponent, + primaryNavigation, + secondaryNavigation, +} from '@mfplayground/hearthhub-ui'; +import type { ReactNode } from 'react'; +import { Link, useLocation } from 'react-router-dom'; + +function SidebarLink({ + count, + icon, + isActive, + label, + to, +}: { + count?: string; + icon: IconComponent; + isActive?: boolean; + label: string; + to?: string; +}) { + const content = ( + + + + + {label} + + + {count ? ( + + {count} + + ) : null} + + ); + + return to ? ( + + {content} + + ) : ( + content + ); +} + +function Sidebar() { + const location = useLocation(); + + return ( + + + + + HearthHub + + + + + + + + Oakridge Properties + + + Portfolio · 24 homes + + + + + + + {primaryNavigation.map((item) => ( + + ))} + + + + + + {secondaryNavigation.map((item) => ( + + ))} + + + + + + + + Need help now? + + + Talk to our care team 24/7. + + + + + + + + + + + Jennifer Park + + + Admin + + + + + + ); +} + +function TopBar() { + return ( + + + + + Good morning, Jennifer + + + Here's what's happening at Oakridge Properties. + + + + + + + + + + + + + + + + + + + ); +} + +function MobileHeader() { + return ( + + + + + HearthHub + + + + + ); +} + +export function AppFrame({ children }: { children: ReactNode }) { + return ( + + + + + + + {children} + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/HeroPanel.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/HeroPanel.tsx new file mode 100644 index 0000000..490d459 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/HeroPanel.tsx @@ -0,0 +1,40 @@ +import { Box, Button, Heading, Icon, Text } from '@chakra-ui/react'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import { Link } from 'react-router-dom'; +import commandCenterImage from '../../../assets/hearthhub-command-center.png'; + +export function HeroPanel() { + return ( + + + Command Center + + + All systems. Every home. + + + Monitor visits, manage vendors, and keep your homes running smoothly. + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/RightRail.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/RightRail.tsx new file mode 100644 index 0000000..a507b59 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/RightRail.tsx @@ -0,0 +1,251 @@ +import { + Avatar, + Badge, + Box, + Button, + Circle, + Divider, + HStack, + Heading, + Icon, + Progress, + Stack, + Text, +} from '@chakra-ui/react'; +import { + CalendarDaysIcon, + CheckCircleIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; +import { + AppIcon, + ShellPanel, + issueTriage, + readinessTasks, + routeOwners, +} from '@mfplayground/hearthhub-ui'; + +function UpcomingVisitCard() { + return ( + } + title="Upcoming Visit" + > + + + + MAY + + + 28 + + + WED + + + + + HVAC Seasonal Tune-Up + Scheduled + + 9:00 - 11:00 AM + 123 Maple Ridge Dr + + + + Technician: Alex Ramirez + + + + + + + + + + ); +} + +function SeasonalReadinessCard() { + return ( + + View all + + } + title="Seasonal Readiness" + > + + + + + 72% + + + Ready + + + + + You're on track for summer. + + 2 of 7 seasonal tasks remaining. + + + + } spacing={2}> + {readinessTasks.map(([task, date, state]) => ( + + + {state === 'open' ? ( + + ) : ( + + )} + {task} + + + {date} + + + ))} + + + + ); +} + +function IssueTriageCard() { + return ( + + View all (3) + + } + title="Issue Triage" + > + + {issueTriage.map(([issue, home, level, time]) => ( + + + + ! + + + + {issue} + + + {home} + + + + + + {level} + + + {time} + + + + ))} + + + + ); +} + +function RouteOwnershipCard() { + return ( + + View map + + } + title="Route Ownership" + > + + {routeOwners.map(([owner, count, progress]) => ( + + + + {owner} + + + {count} + + + + {progress}% + + + ))} + + + + ); +} + +export function RightRail() { + return ( + + + + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/ServicePlansPanel.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/ServicePlansPanel.tsx new file mode 100644 index 0000000..061fa39 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/ServicePlansPanel.tsx @@ -0,0 +1,57 @@ +import { + Badge, + Box, + Button, + Circle, + Divider, + HStack, + Stack, + Text, +} from '@chakra-ui/react'; +import { AppIcon, ShellPanel, servicePlans } from '@mfplayground/hearthhub-ui'; +import { Link } from 'react-router-dom'; + +export function ServicePlansPanel() { + return ( + + Manage + + } + title="Service Plans" + > + } spacing={3}> + {servicePlans.map((plan) => ( + + + + + + + {plan.displayName} + + {plan.homes} + + + + + {plan.status} + + + ))} + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/StatsStrip.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/StatsStrip.tsx new file mode 100644 index 0000000..ee0f0c7 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/StatsStrip.tsx @@ -0,0 +1,64 @@ +import { + Badge, + Box, + HStack, + SimpleGrid, + Stat, + StatHelpText, + StatLabel, + StatNumber, +} from '@chakra-ui/react'; +import { shellStats } from '@mfplayground/hearthhub-ui'; + +function StatTile({ + delta, + help, + label, + tone, + value, +}: (typeof shellStats)[number]) { + const deltaColor = + tone === 'green' + ? 'green' + : tone === 'red' + ? 'red' + : tone === 'gold' + ? 'yellow' + : 'gray'; + + return ( + + + + {label} + + + {value} + + {delta} + + + + {help} + + + + ); +} + +export function StatsStrip() { + return ( + + {shellStats.map((stat) => ( + + ))} + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/TrustedVendorsPanel.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/TrustedVendorsPanel.tsx new file mode 100644 index 0000000..82174b3 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/TrustedVendorsPanel.tsx @@ -0,0 +1,103 @@ +import { + Box, + Button, + Circle, + HStack, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from '@chakra-ui/react'; +import { + AppIcon, + ShellPanel, + trustedVendors, +} from '@mfplayground/hearthhub-ui'; + +export function TrustedVendorsPanel() { + return ( + + View all + + } + title="Trusted Vendors" + > + + + + + + + + + + + + {trustedVendors.map((vendor) => ( + + + + + + + + ))} + +
VendorCategoriesRatingPerf.Last
+ + + + + + {vendor.name} + + {vendor.category} + + + + + + {vendor.serviceIcons.map((serviceIcon) => ( + + {serviceIcon} + + ))} + + + + {vendor.rating} + + ★★★★★ + + + + + {vendor.performance}% + + + On-time + + {vendor.lastVisit}
+ +
+ ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/VisitTable.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/VisitTable.tsx new file mode 100644 index 0000000..04a8676 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/components/dashboard/VisitTable.tsx @@ -0,0 +1,125 @@ +import { + Avatar, + Badge, + Button, + HStack, + Icon, + Tab, + TabList, + TabPanel, + TabPanels, + Table, + Tabs, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from '@chakra-ui/react'; +import { CalendarDaysIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; +import { AppIcon, ShellPanel, visitRows } from '@mfplayground/hearthhub-ui'; + +export function VisitTable() { + return ( + + + + + {' '} + Upcoming Visits + + Recent Activity + Messages + Documents + + + + + {['Next 7 Days', 'Next 30 Days', 'All'].map((filter, index) => ( + + ))} + {['All Homes', 'All Services', 'All Vendors'].map((filter) => ( + + ))} + + + + + + + + + + + + + + + {visitRows.map((visit) => ( + + + + + + + + + + ))} + +
DateTimeServiceHomeVendorTechnicianStatus
{visit.date}{visit.time}{visit.service}{visit.home}{visit.vendor} + + + {visit.tech} + + + + {visit.status} + +
+ +
+ + Recent service notes, remote deploys, and approval events appear + here. + + + Unread homeowner and vendor messages are grouped by property and + visit. + + + Invoices, inspection reports, and plan documents are attached to + each route. + +
+
+
+ ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/pages/Diagnostics.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/pages/Diagnostics.tsx new file mode 100644 index 0000000..6e94dd2 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/pages/Diagnostics.tsx @@ -0,0 +1,76 @@ +import { + Box, + Divider, + List, + ListIcon, + ListItem, + SimpleGrid, + Stack, + Tag, + TagLabel, + Text, +} from '@chakra-ui/react'; +import { + ArrowPathIcon, + CheckCircleIcon, + Cog6ToothIcon, + CubeTransparentIcon, + PuzzlePieceIcon, +} from '@heroicons/react/24/outline'; +import { AppIcon, SectionCard } from '@mfplayground/hearthhub-ui'; +import { LegacyRuntimeBadge } from '@mfplayground/legacy-react-widget'; + +export function Diagnostics() { + return ( + + + + Runtime board + + + Production diagnostics for the federated HearthHub app. + + + Developers use this board to confirm that customer-facing screens + still expose accurate component trees, stable selection targets, and + route ownership after each remote ships. + + + + + + window.__AGENTIC_REACT__ + available in the shell host and remotes + + + + {[ + 'catalog/Routes owns service plans', + 'profile/Routes owns household context', + ].map((item) => ( + + + {item} + + ))} + + + + + React 18.2.0 and React Router 6.4.2 remain strict singletons. + + + + module-federation.js enforced + + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/pages/ShellHome.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/pages/ShellHome.tsx new file mode 100644 index 0000000..69466dc --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/pages/ShellHome.tsx @@ -0,0 +1,28 @@ +import { Grid, Stack } from '@chakra-ui/react'; +import { HeroPanel } from '../components/dashboard/HeroPanel'; +import { RightRail } from '../components/dashboard/RightRail'; +import { ServicePlansPanel } from '../components/dashboard/ServicePlansPanel'; +import { StatsStrip } from '../components/dashboard/StatsStrip'; +import { TrustedVendorsPanel } from '../components/dashboard/TrustedVendorsPanel'; +import { VisitTable } from '../components/dashboard/VisitTable'; + +export function ShellHome() { + return ( + + + + + + + + + + + + + ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/remotes.d.ts b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/remotes.d.ts new file mode 100644 index 0000000..5ee9892 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/app/remotes.d.ts @@ -0,0 +1,13 @@ +declare module 'catalog/Routes' { + import type { ComponentType } from 'react'; + + const Routes: ComponentType; + export default Routes; +} + +declare module 'profile/Routes' { + import type { ComponentType } from 'react'; + + const Routes: ComponentType; + export default Routes; +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets.d.ts b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets.d.ts new file mode 100644 index 0000000..fc781e8 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const src: string; + export default src; +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets/.gitkeep b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets/.gitkeep @@ -0,0 +1 @@ + diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets/hearthhub-command-center.png b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets/hearthhub-command-center.png new file mode 100644 index 0000000..f5c240a Binary files /dev/null and b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets/hearthhub-command-center.png differ diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets/hearthhub-operations.png b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets/hearthhub-operations.png new file mode 100644 index 0000000..604002e Binary files /dev/null and b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/assets/hearthhub-operations.png differ diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/bootstrap.tsx b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/bootstrap.tsx new file mode 100644 index 0000000..8d5a90d --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/bootstrap.tsx @@ -0,0 +1,19 @@ +import { createRoot } from 'react-dom/client'; +import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { App } from './app/App'; +import './styles.css'; + +const router = createBrowserRouter([ + { + path: '*', + element: , + }, +]); + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Missing #root element for Nx module federation shell'); +} + +createRoot(rootElement).render(); diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/index.html b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/index.html new file mode 100644 index 0000000..f223517 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/index.html @@ -0,0 +1,12 @@ + + + + + Nx Module Federation Shell + + + + +
+ + diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/main.ts b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/main.ts new file mode 100644 index 0000000..b93c7a0 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/main.ts @@ -0,0 +1 @@ +import('./bootstrap'); diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/polyfills.ts b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/polyfills.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/polyfills.ts @@ -0,0 +1 @@ +export {}; diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/src/styles.css b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/styles.css new file mode 100644 index 0000000..9376b71 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/src/styles.css @@ -0,0 +1,194 @@ +:root { + color: #172033; + background: #f7f8fb; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: + linear-gradient(135deg, rgba(59, 130, 246, 0.12), transparent 32rem), + linear-gradient(180deg, #f7f8fb 0%, #eef2f7 100%); +} + +button, +a { + font: inherit; +} + +.shell { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 24px 0 48px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 64px; +} + +.brand { + color: #111827; + font-weight: 800; + text-decoration: none; +} + +nav { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +nav a { + border-radius: 8px; + color: #475569; + font-size: 14px; + font-weight: 700; + padding: 9px 12px; + text-decoration: none; +} + +nav a.active { + background: #111827; + color: #ffffff; +} + +.workspace-panel { + margin-top: 48px; + padding: 48px; + border: 1px solid rgba(148, 163, 184, 0.36); + border-radius: 8px; + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.08); +} + +.section-label { + margin: 0 0 14px; + color: #2563eb; + font-size: 13px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +h1, +h2 { + max-width: 780px; + margin: 0; + color: #0f172a; + font-size: clamp(36px, 6vw, 68px); + line-height: 0.98; +} + +h2 { + font-size: clamp(30px, 4vw, 48px); +} + +.hero-copy { + max-width: 760px; + margin: 24px 0 0; + color: #475569; + font-size: 18px; + line-height: 1.7; +} + +.team-switcher { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 32px 0; +} + +.team-switcher button { + min-height: 40px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #ffffff; + color: #475569; + cursor: pointer; + font-size: 14px; + font-weight: 800; + padding: 0 14px; +} + +.team-switcher button.selected { + border-color: #1d4ed8; + background: #1d4ed8; + color: #ffffff; +} + +.summary-grid, +.diagnostic-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.summary-grid article, +.diagnostic-list > div { + min-height: 144px; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #ffffff; + padding: 20px; +} + +.summary-grid span, +.diagnostic-list span { + display: block; + color: #64748b; + font-size: 13px; + font-weight: 800; + margin-bottom: 10px; +} + +.summary-grid strong, +.diagnostic-list strong { + display: block; + color: #111827; + font-size: 20px; + line-height: 1.2; +} + +.summary-grid p { + color: #475569; + line-height: 1.6; + margin: 16px 0 0; +} + +.diagnostic-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 28px; +} + +@media (max-width: 760px) { + .topbar { + align-items: flex-start; + flex-direction: column; + } + + nav { + justify-content: flex-start; + } + + .workspace-panel { + margin-top: 28px; + padding: 28px; + } + + .summary-grid, + .diagnostic-list { + grid-template-columns: 1fr; + } +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/tsconfig.app.json b/playground/agentic-react-nx-module-federation-playground/apps/shell/tsconfig.app.json new file mode 100644 index 0000000..4588365 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts", "src/polyfills.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/apps/shell/webpack.config.js b/playground/agentic-react-nx-module-federation-playground/apps/shell/webpack.config.js new file mode 100644 index 0000000..612c63a --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/apps/shell/webpack.config.js @@ -0,0 +1,81 @@ +const path = require('node:path'); +const { composePlugins, withNx } = require('@nrwl/webpack'); +const { withReact } = require('@nrwl/react'); +const { merge } = require('webpack-merge'); +const withModuleFederation = require('../../module-federation'); +const { replaceDefinePlugin } = require('../../webpack.config.base'); +const moduleFederationConfig = require('./module-federation.config'); +const workspaceRoot = path.resolve(__dirname, '../..'); +const toolkit = { + tuningModal: { + classNames: { + surface: 'nx-shell-tuning-surface', + panel: 'nx-shell-tuning-panel', + control: 'nx-shell-tuning-control', + }, + tokens: { + panelRadius: '12px', + controlRadius: '9px', + primaryButtonBackground: '#4338ca', + primaryButtonColor: '#ffffff', + panelShadow: '0 24px 72px rgba(67, 56, 202, 0.22)', + }, + styles: { + surface: { + filter: 'drop-shadow(0 18px 40px rgba(67, 56, 202, 0.15))', + }, + panel: { + border: '1px solid rgba(67, 56, 202, 0.24)', + }, + targetTag: { + background: '#eef2ff', + color: '#4338ca', + }, + sectionTitle: { + color: '#4338ca', + }, + }, + }, +}; + +module.exports = composePlugins( + withNx(), + withReact(), + async (config, nxContext) => { + const federatedModules = await withModuleFederation( + moduleFederationConfig, + ); + const { default: withAgenticReactWebpack } = await import('@agentic-react/webpack'); + const mergedConfig = replaceDefinePlugin( + merge(federatedModules(config, nxContext), { + devServer: { + historyApiFallback: true, + client: { + overlay: false, + }, + }, + resolve: { + fallback: { + fs: false, + net: false, + tls: false, + }, + }, + }), + ); + + return withAgenticReactWebpack( + mergedConfig, + { + mode: + nxContext?.context?.configurationName === 'production' + ? 'production' + : 'development', + }, + { + rootDir: workspaceRoot, + toolkit, + }, + ); + }, +); diff --git a/playground/agentic-react-nx-module-federation-playground/babel.config.json b/playground/agentic-react-nx-module-federation-playground/babel.config.json new file mode 100644 index 0000000..3c53b4f --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/babel.config.json @@ -0,0 +1,17 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": ">0.25%, not dead" + } + ], + [ + "@babel/preset-react", + { + "runtime": "automatic" + } + ], + "@babel/preset-typescript" + ] +} diff --git a/playground/agentic-react-nx-module-federation-playground/e2e/runtime.spec.js b/playground/agentic-react-nx-module-federation-playground/e2e/runtime.spec.js new file mode 100644 index 0000000..3d1a896 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/e2e/runtime.spec.js @@ -0,0 +1,293 @@ +import { expect, test } from '@playwright/test'; + +const shellPort = process.env.NX_MF_SHELL_PORT || '4200'; +const catalogPort = process.env.NX_MF_CATALOG_PORT || '4201'; +const profilePort = process.env.NX_MF_PROFILE_PORT || '4202'; +const MCP_SERVER_URL = `http://127.0.0.1:${shellPort}/mcp`; +const REMOTE_ENTRY_URLS = [ + [`http://127.0.0.1:${catalogPort}/remoteEntry.js`, 'var catalog'], + [`http://127.0.0.1:${profilePort}/remoteEntry.js`, 'var profile'], +]; + +const waitForRemoteEntries = async (request) => { + for (const [remoteEntryUrl, expectedContainer] of REMOTE_ENTRY_URLS) { + await expect + .poll( + async () => { + const response = await request.get(remoteEntryUrl).catch(() => null); + if (response?.status() !== 200) { + return false; + } + return (await response.text()).includes(expectedContainer); + }, + { timeout: 30000 }, + ) + .toBe(true); + } +}; + +const createMcpClient = async () => { + const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([ + import('@modelcontextprotocol/sdk/client/index.js'), + import('@modelcontextprotocol/sdk/client/streamableHttp.js'), + ]); + const client = new Client({ + name: 'nx-module-federation-playground-mcp-e2e', + version: '0.0.0', + }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL)); + await client.connect(transport); + return { client, transport }; +}; + +const parseToolResponse = (result) => { + const textContent = result.content.find((content) => content.type === 'text'); + if (!textContent || typeof textContent.text !== 'string') { + return null; + } + return JSON.parse(textContent.text); +}; + +const getToolText = (result) => { + const textContent = result.content.find((content) => content.type === 'text'); + if (!textContent || typeof textContent.text !== 'string') { + return ''; + } + return textContent.text; +}; + +const expectProfileMemberCardSource = (context) => { + expect(context.resolvedSources).toContainEqual( + expect.objectContaining({ + componentName: 'ProfileMemberCard', + filePath: expect.stringContaining('ProfileMemberCard.tsx'), + }), + ); + expect(context.sourceSnippets).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining('ProfileMemberCard.tsx'), + snippet: expect.stringContaining('function ProfileMemberCard'), + }), + ); + expect(context.sourceSnippets).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining('ProfileMemberCard.tsx'), + snippet: expect.stringContaining('profile-member-${memberId}'), + }), + ); +}; + +const selectRemoteProfileMember = async (page, request) => { + await waitForRemoteEntries(request); + await page.goto('/profile'); + await expect(page.getByText('Federated team profile routes.')).toBeVisible(); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + await page.locator('#profile-member-sam-rivera').click(); + + const didCapture = await page.evaluate(() => + Boolean(window.__AGENTIC_REACT__?.getLastSelectionContext()), + ); + if (!didCapture) { + await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + await page.locator('#profile-member-sam-rivera').click(); + } + + await page.waitForFunction(() => + window.__AGENTIC_REACT__?.getLastSelectionContext(), + ); +}; + +test('nx module federation playground injects runtime globals', async ({ + page, +}) => { + await page.goto('/'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const runtimeShape = await page.evaluate(() => ({ + hasRuntime: Boolean(window.__AGENTIC_REACT__), + hasTools: Boolean(window.__AGENTIC_REACT_TOOLS__), + hasSelectionMode: typeof window.__AGENTIC_REACT__?.setSelectionMode, + hasGetContext: typeof window.__AGENTIC_REACT__?.getLastSelectionContext, + hasCopyContext: typeof window.__AGENTIC_REACT__?.copyLastSelectionContext, + hasRegisterTuningModalExtension: + typeof window.__AGENTIC_REACT__?.registerTuningModalExtension, + tuningSurfaceClassName: + window.__AGENTIC_REACT_CONFIG__?.toolkit?.tuningModal?.classNames + ?.surface, + tuningPanelClassName: + window.__AGENTIC_REACT_CONFIG__?.toolkit?.tuningModal?.classNames?.panel, + unregisterType: (() => { + const unregister = + window.__AGENTIC_REACT__?.registerTuningModalExtension({ + id: 'nx-runtime-smoke', + }); + unregister?.(); + return typeof unregister; + })(), + })); + + expect(runtimeShape.hasRuntime).toBe(true); + expect(runtimeShape.hasTools).toBe(true); + expect(runtimeShape.hasSelectionMode).toBe('function'); + expect(runtimeShape.hasGetContext).toBe('function'); + expect(runtimeShape.hasCopyContext).toBe('function'); + expect(runtimeShape.hasRegisterTuningModalExtension).toBe('function'); + expect(runtimeShape.tuningSurfaceClassName).toBe('nx-shell-tuning-surface'); + expect(runtimeShape.tuningPanelClassName).toBe('nx-shell-tuning-panel'); + expect(runtimeShape.unregisterType).toBe('function'); +}); + +test('nx module federation host exposes remote React context to MCP', async ({ + page, + request, +}) => { + await waitForRemoteEntries(request); + await page.goto('/catalog'); + await expect(page.getByText('Federated product routes.')).toBeVisible(); + await expect(page.getByText('React Router 6.4.2 route island')).toBeVisible(); + await page.goto('/profile'); + await expect(page.getByText('Federated team profile routes.')).toBeVisible(); + await expect(page.getByText('Component inspection')).toBeVisible(); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const { client, transport } = await createMcpClient(); + try { + const treeRaw = await client.callTool({ + name: 'get-component-tree', + arguments: { + allComponents: true, + }, + }); + const tree = parseToolResponse(treeRaw); + expect(JSON.stringify(tree)).toContain('ProfileRoutes'); + } finally { + await transport.close(); + } +}); + +test('nx module federation selection captures remote component source context', async ({ + page, + request, +}) => { + await selectRemoteProfileMember(page, request); + + const selectionContext = await page.evaluate(() => + window.__AGENTIC_REACT__.getLastSelectionContext(), + ); + + expect(selectionContext.componentName).toBe('ProfileMemberCard'); + expect(selectionContext.selector).toBe('#profile-member-sam-rivera'); + expect(selectionContext.resolvedSources).toContainEqual( + expect.objectContaining({ + componentName: 'ProfileMemberCard', + filePath: expect.stringContaining('ProfileMemberCard.tsx'), + }), + ); + + const { client, transport } = await createMcpClient(); + try { + const selectedContextRaw = await client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 12, + maxFiles: 3, + }, + }); + const selectedContext = parseToolResponse(selectedContextRaw); + expect(selectedContext.success).toBe(true); + expect(selectedContext.context.componentName).toBe('ProfileMemberCard'); + expect(selectedContext.context.selector).toBe('#profile-member-sam-rivera'); + expect(selectedContext.summary).toContain('ProfileMemberCard'); + expect(selectedContext.summary).toContain('function ProfileMemberCard'); + expectProfileMemberCardSource(selectedContext.context); + + const reactSourceRaw = await client.callTool({ + name: 'get-react-source-code', + arguments: { + queries: ['article profile-member-sam-rivera'], + maxMatches: 3, + includeSourceSnippets: true, + contextLines: 12, + maxFiles: 3, + }, + }); + const reactSource = parseToolResponse(reactSourceRaw); + expect(reactSource.success).toBe(true); + expect(reactSource.chosenMatch.selector).toBe( + '#profile-member-sam-rivera', + ); + expect(reactSource.context.componentName).toBe('ProfileMemberCard'); + expect(reactSource.summary).toContain('function ProfileMemberCard'); + expectProfileMemberCardSource(reactSource.context); + + const highlightRaw = await client.callTool({ + name: 'highlight-component', + arguments: { componentName: 'ProfileMemberCard' }, + }); + expect(getToolText(highlightRaw)).toContain('highlighted'); + } finally { + await transport.close(); + } +}); + +test('nx selection attributes external package components to local usage source', async ({ + page, + request, +}) => { + await waitForRemoteEntries(request); + await page.goto('/profile'); + await expect(page.getByText('Federated team profile routes.')).toBeVisible(); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + await page.locator('#profile-external-component-probe').click(); + await page.waitForFunction(() => + window.__AGENTIC_REACT__?.getLastSelectionContext(), + ); + + const { client, transport } = await createMcpClient(); + try { + const selectedContextRaw = await client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 24, + maxFiles: 2, + }, + }); + const selectedContext = parseToolResponse(selectedContextRaw); + const selectionContext = selectedContext.context; + + expect(selectionContext.externalComponent).toEqual( + expect.objectContaining({ + componentName: expect.any(String), + packageName: expect.stringMatching(/^@chakra-ui\//), + usedBy: expect.objectContaining({ + componentName: 'ExternalDependencySelectionProbe', + filePath: expect.stringContaining( + 'ExternalDependencySelectionProbe.tsx', + ), + }), + }), + ); + expect(selectionContext.sourcePreview).toContain( + 'selected external component:', + ); + expect(selectionContext.sourcePreview).toContain( + 'ExternalDependencySelectionProbe.tsx', + ); + expect(selectionContext.sourceSnippets).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining( + 'ExternalDependencySelectionProbe.tsx', + ), + snippet: expect.stringContaining('Review access note'), + }), + ); + } finally { + await transport.close(); + } +}); diff --git a/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/project.json b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/project.json new file mode 100644 index 0000000..bcb2ac6 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/project.json @@ -0,0 +1,8 @@ +{ + "name": "hearthhub-ui", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/hearthhub-ui/src", + "projectType": "library", + "targets": {}, + "tags": ["scope:shared", "type:ui"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/components.tsx b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/components.tsx new file mode 100644 index 0000000..ebf342f --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/components.tsx @@ -0,0 +1,199 @@ +import { + Badge, + Box, + Card, + CardBody, + Flex, + HStack, + Heading, + Icon, + Stat, + StatHelpText, + StatLabel, + StatNumber, + Text, +} from '@chakra-ui/react'; +import type { ComponentType, ReactNode, SVGProps } from 'react'; + +export type IconComponent = ComponentType>; + +export function AppIcon({ + icon, + size = 5, +}: { icon: IconComponent; size?: number }) { + return ; +} + +export function IconBadge({ + bg = 'hearth.50', + color = 'hearth.700', + icon, + size = '40px', +}: { + bg?: string; + color?: string; + icon: IconComponent; + size?: string; +}) { + return ( + + + + ); +} + +export function ShellPanel({ + action, + children, + title, +}: { + action?: ReactNode; + children: ReactNode; + title: string; +}) { + return ( + + + + {title} + {action} + + {children} + + + ); +} + +export function SectionCard({ + children, + icon, + title, +}: { + children: ReactNode; + icon: IconComponent; + title: string; +}) { + return ( + + + + + {title} + + {children} + + + ); +} + +export function RemotePageHeader({ + action, + children, + icon, + label, + title, +}: { + action?: ReactNode; + children: ReactNode; + icon: IconComponent; + label: string; + title: string; +}) { + return ( + + + + + {label} + + + {title} + + + {children} + + + {action} + + ); +} + +export function MetricCard({ + help, + label, + value, +}: { + help: string; + label: string; + value: string; +}) { + return ( + + + + + {label} + + {value} + {help} + + + + ); +} + +export function StatusBadge({ value }: { value: string }) { + const normalized = value.toLowerCase(); + const colorScheme = + normalized.includes('active') || + normalized.includes('confirmed') || + normalized.includes('stable') + ? 'green' + : normalized.includes('high') + ? 'red' + : 'orange'; + + return {value}; +} diff --git a/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/data.ts b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/data.ts new file mode 100644 index 0000000..3c5ce03 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/data.ts @@ -0,0 +1,284 @@ +import { + Bars3BottomLeftIcon, + CalendarDaysIcon, + ClipboardDocumentCheckIcon, + Cog6ToothIcon, + CreditCardIcon, + DocumentTextIcon, + EnvelopeIcon, + FolderIcon, + HomeModernIcon, + PuzzlePieceIcon, + ShieldCheckIcon, + SparklesIcon, + WrenchScrewdriverIcon, +} from '@heroicons/react/24/outline'; +import type { IconComponent } from './components'; + +export const primaryNavigation = [ + { to: '/', label: 'Home', icon: HomeModernIcon }, + { to: '/catalog', label: 'Services', icon: WrenchScrewdriverIcon }, + { to: '/profile', label: 'Household', icon: PuzzlePieceIcon }, + { to: '/diagnostics', label: 'Diagnostics', icon: Bars3BottomLeftIcon }, +]; + +export const secondaryNavigation = [ + { label: 'Visits', count: '8', icon: CalendarDaysIcon }, + { label: 'Requests', count: '3', icon: ClipboardDocumentCheckIcon }, + { label: 'Vendors', icon: ShieldCheckIcon }, + { label: 'Subscriptions', icon: Bars3BottomLeftIcon }, + { label: 'Documents', icon: DocumentTextIcon }, + { label: 'Messages', count: '2', icon: EnvelopeIcon }, + { label: 'Billing', icon: CreditCardIcon }, + { label: 'Reports', icon: FolderIcon }, + { label: 'Settings', icon: Cog6ToothIcon }, +]; + +export const shellStats = [ + { + label: 'Open Requests', + value: '3', + delta: '+1', + help: '2 urgent', + tone: 'red', + }, + { + label: 'Upcoming Visits', + value: '8', + delta: '+2', + help: 'Next 7 days', + tone: 'red', + }, + { + label: 'Active Subscriptions', + value: '12', + delta: '-', + help: 'Across 24 homes', + tone: 'gray', + }, + { + label: 'Monthly Spend', + value: '$18,540', + delta: '↓ 8%', + help: 'vs Apr 2025', + tone: 'green', + }, + { + label: 'Vendor Rating', + value: '4.8', + delta: '☆', + help: 'Average', + tone: 'gold', + }, +]; + +export type Vendor = { + category: string; + color: string; + icon: IconComponent; + lastVisit: string; + name: string; + performance: number; + rating: string; + serviceIcons: string[]; +}; + +export const trustedVendors: Vendor[] = [ + { + name: 'Evergreen HVAC', + category: 'HVAC', + rating: '4.9', + performance: 98, + lastVisit: 'May 20, 2025', + icon: HomeModernIcon, + color: 'hearth.700', + serviceIcons: ['❄', '🔥', '⚙'], + }, + { + name: 'Pinnacle Plumbing', + category: 'Plumbing', + rating: '4.8', + performance: 96, + lastVisit: 'May 18, 2025', + icon: WrenchScrewdriverIcon, + color: 'gray.600', + serviceIcons: ['💧', '🔧'], + }, + { + name: 'Brightview Electric', + category: 'Electrical', + rating: '4.7', + performance: 94, + lastVisit: 'May 15, 2025', + icon: SparklesIcon, + color: 'gold.500', + serviceIcons: ['⚡', '▣'], + }, + { + name: 'GreenScape Lawns', + category: 'Landscaping', + rating: '4.8', + performance: 97, + lastVisit: 'May 12, 2025', + icon: ShieldCheckIcon, + color: 'green.600', + serviceIcons: ['🌿', '▲'], + }, +]; + +export const servicePlans = [ + { + id: 'family-plus', + name: 'Family Plus seasonal care', + displayName: 'Home Essentials Plan', + technicalName: 'React Router 6.4.2 route island', + owner: 'services marketplace', + status: 'Enrolling', + price: '$89/mo', + readiness: 92, + homes: '12 homes', + icon: HomeModernIcon, + color: 'gold.600', + summary: + 'Quarterly HVAC, plumbing, appliance, and safety checks bundled with emergency booking priority.', + }, + { + id: 'leak-watch', + name: 'Leak watch response', + displayName: 'HVAC Protection Plan', + technicalName: 'Remote plan detail boundary', + owner: 'home protection', + status: 'Active', + price: '$24/mo', + readiness: 81, + homes: '10 homes', + icon: WrenchScrewdriverIcon, + color: 'teal.600', + summary: + 'Moisture sensor dispatch, same-day plumber coordination, and repair documentation for insurance claims.', + }, + { + id: 'plumbing-care', + name: 'Plumbing Care Plan', + displayName: 'Plumbing Care Plan', + technicalName: 'Catalog remote service path', + owner: 'home protection', + status: 'Active', + price: '$34/mo', + readiness: 84, + homes: '8 homes', + icon: ShieldCheckIcon, + color: 'blue.600', + summary: + 'Annual plumbing inspection, fixture leak response, water heater reminders, and vetted provider coordination.', + }, + { + id: 'electrical-safety', + name: 'Electrical Safety Plan', + displayName: 'Electrical Safety Plan', + technicalName: 'Catalog remote commerce path', + owner: 'new home onboarding', + status: 'Expiring Soon', + price: '$42/mo', + readiness: 68, + homes: '6 homes', + icon: SparklesIcon, + color: 'orange.500', + summary: + 'Outlet inspection, panel review, smoke detector checks, and priority scheduling for electrical repairs.', + }, +]; + +export const visitRows = [ + { + date: 'May 28, 2025', + time: '9:00 - 11:00 AM', + service: 'HVAC Seasonal Tune-Up', + home: '123 Maple Ridge Dr', + vendor: 'Evergreen HVAC', + tech: 'Alex Ramirez', + status: 'Scheduled', + }, + { + date: 'May 29, 2025', + time: '1:00 - 3:00 PM', + service: 'Plumbing Inspection', + home: '456 Hillcrest Ave', + vendor: 'Pinnacle Plumbing', + tech: 'Jordan Lee', + status: 'Scheduled', + }, + { + date: 'May 30, 2025', + time: '10:00 AM - 12:00 PM', + service: 'Electrical Safety Check', + home: '789 Cedar Ln', + vendor: 'Brightview Electric', + tech: 'Taylor Morgan', + status: 'Scheduled', + }, + { + date: 'May 31, 2025', + time: '8:00 - 10:00 AM', + service: 'Lawn Treatment', + home: '321 Oakwood Dr', + vendor: 'GreenScape Lawns', + tech: 'Casey Patel', + status: 'Confirmed', + }, +]; + +export const readinessTasks = [ + ['AC system tune-up', 'Completed May 4', 'done'], + ['Gutter cleaning', 'Completed May 10', 'done'], + ['Deck inspection', 'Due Jun 1', 'warning'], + ['Pool opening', 'Due Jun 7', 'open'], + ['Irrigation check', 'Due Jun 14', 'open'], +] as const; + +export const issueTriage = [ + ['No cool air on 2nd floor', '123 Maple Ridge Dr', 'High', 'Just now'], + ['Kitchen faucet leak', '456 Hillcrest Ave', 'Medium', '1h ago'], + ['Outdoor outlet not working', '789 Cedar Ln', 'Low', '3h ago'], +] as const; + +export const routeOwners = [ + ['Alex Ramirez', '8 visits', 95], + ['Jordan Lee', '7 visits', 88], + ['Taylor Morgan', '6 visits', 81], + ['Casey Patel', '5 visits', 76], +] as const; + +export const vendorSignals = [ + 'Background-checked providers with household access notes', + 'Arrival windows synced to the shell work queue', + 'Service detail pages owned by the catalog remote team', +]; + +export const householdMembers = [ + { + name: 'Mira Chen', + role: 'Primary homeowner', + focus: 'Service approval and budget thresholds', + note: 'Prefers text confirmations before technicians enter the home office.', + }, + { + name: 'Owen Park', + role: 'Weekend coordinator', + focus: 'Vendor access and visit handoff', + note: 'Available for Saturday arrivals and outdoor project sign-off.', + }, + { + name: 'Sam Rivera', + role: 'Care logistics lead', + focus: 'Component inspection', + note: 'Keeps inspection notes current for source-context and selector checks.', + }, +]; + +export const householdPreferences = [ + 'Technicians can use the side gate after arrival is confirmed.', + 'Quiet hours are 1:00 PM to 3:00 PM for remote work and childcare.', + 'Use fragrance-free cleaning supplies in bedrooms and nursery.', + 'Pets must remain upstairs during plumbing and electrical visits.', +]; diff --git a/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/index.ts b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/index.ts new file mode 100644 index 0000000..d809aa8 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/index.ts @@ -0,0 +1,3 @@ +export * from './components'; +export * from './data'; +export * from './theme'; diff --git a/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/theme.ts b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/theme.ts new file mode 100644 index 0000000..f95d7c5 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/src/theme.ts @@ -0,0 +1,45 @@ +import { extendTheme } from '@chakra-ui/react'; + +export const hearthHubTheme = extendTheme({ + fonts: { + body: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + heading: + 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + }, + radii: { + md: '8px', + lg: '8px', + xl: '8px', + }, + colors: { + hearth: { + 50: '#eef8f3', + 100: '#d7ede2', + 500: '#177052', + 600: '#075f45', + 700: '#064b39', + 800: '#06372c', + 900: '#03281f', + }, + ember: { + 50: '#fff0eb', + 100: '#ffd9cc', + 500: '#dc5135', + 700: '#9d341f', + }, + gold: { + 50: '#fff8e6', + 100: '#f8e7b3', + 500: '#c28b22', + 700: '#7d5615', + }, + }, + styles: { + global: { + body: { + bg: '#f6f4ee', + color: '#111827', + }, + }, + }, +}); diff --git a/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/tsconfig.lib.json b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/tsconfig.lib.json new file mode 100644 index 0000000..2bef5a8 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/hearthhub-ui/tsconfig.lib.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/project.json b/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/project.json new file mode 100644 index 0000000..013614c --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/project.json @@ -0,0 +1,8 @@ +{ + "name": "legacy-react-widget", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/legacy-react-widget/src", + "projectType": "library", + "targets": {}, + "tags": ["scope:shared", "type:legacy"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/src/LegacyRuntimeBadge.tsx b/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/src/LegacyRuntimeBadge.tsx new file mode 100644 index 0000000..0a322fb --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/src/LegacyRuntimeBadge.tsx @@ -0,0 +1,37 @@ +import type { ReactElement } from 'react'; + +export function LegacyRuntimeBadge(): ReactElement { + return ( +
+ + Legacy dependency + + + React 17 package present + +
+ ); +} diff --git a/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/src/index.ts b/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/src/index.ts new file mode 100644 index 0000000..70c24fd --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/src/index.ts @@ -0,0 +1 @@ +export { LegacyRuntimeBadge } from './LegacyRuntimeBadge'; diff --git a/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/tsconfig.lib.json b/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/tsconfig.lib.json new file mode 100644 index 0000000..2bef5a8 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/libs/legacy-react-widget/tsconfig.lib.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/module-federation.js b/playground/agentic-react-nx-module-federation-playground/module-federation.js new file mode 100644 index 0000000..ad4149d --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/module-federation.js @@ -0,0 +1,121 @@ +const { createProjectGraphAsync } = require('@nrwl/devkit'); +const webpack = require('webpack'); + +const rootPackage = require('./package.json'); + +const sharedVersions = { + ...rootPackage.dependencies, + ...rootPackage.devDependencies, +}; + +const singletonPackages = new Set(['react', 'react-dom', 'react-router-dom']); +const projectPortEnvKeys = { + catalog: 'NX_MF_CATALOG_PORT', + profile: 'NX_MF_PROFILE_PORT', + shell: 'NX_MF_SHELL_PORT', +}; + +const getProjectServePort = (project, projectName) => { + const portEnvKey = projectPortEnvKeys[projectName]; + if (portEnvKey && process.env[portEnvKey]) { + return process.env[portEnvKey]; + } + + const serve = project?.data?.targets?.serve || project?.targets?.serve; + const dev = project?.data?.targets?.dev || project?.targets?.dev; + return serve?.options?.port || dev?.options?.port; +}; + +const getRemoteDefinition = (remote, graph, isDevelopment) => { + if (Array.isArray(remote)) { + const [name, url] = remote; + return [name, `${name}@${url.replace(/\/$/, '')}/remoteEntry.js`]; + } + + if (!isDevelopment) { + return [remote, `${remote}@/${remote}/remoteEntry.js`]; + } + + const remoteProject = graph.nodes[remote]; + const port = getProjectServePort(remoteProject, remote); + + if (!port) { + throw new Error( + `Cannot resolve module federation remote "${remote}" because its project.json has no serve/dev port.`, + ); + } + + return [remote, `${remote}@http://127.0.0.1:${port}/remoteEntry.js`]; +}; + +const getSharedConfig = (moduleFederationConfig) => { + const shared = {}; + + for (const packageName of singletonPackages) { + shared[packageName] = { + singleton: true, + strictVersion: true, + requiredVersion: sharedVersions[packageName], + }; + } + + if (typeof moduleFederationConfig.shared === 'function') { + for (const packageName of Object.keys(shared)) { + shared[packageName] = moduleFederationConfig.shared( + packageName, + shared[packageName], + ); + } + } + + return shared; +}; + +const withModuleFederation = async (moduleFederationConfig) => { + const graph = await createProjectGraphAsync(); + + return (config, nxContext = {}) => { + const isProduction = + nxContext?.context?.configurationName === 'production' || + config.mode === 'production'; + const remotes = Object.fromEntries( + (moduleFederationConfig.remotes || []).map((remote) => + getRemoteDefinition(remote, graph, !isProduction), + ), + ); + const projectPort = getProjectServePort( + graph.nodes[moduleFederationConfig.name], + moduleFederationConfig.name, + ); + const publicPath = + !isProduction && projectPort + ? `http://127.0.0.1:${projectPort}/` + : 'auto'; + + return { + ...config, + output: { + ...config.output, + publicPath, + scriptType: 'text/javascript', + uniqueName: moduleFederationConfig.name, + }, + optimization: { + ...config.optimization, + runtimeChunk: false, + }, + plugins: [ + ...(config.plugins || []), + new webpack.container.ModuleFederationPlugin({ + name: moduleFederationConfig.name, + filename: 'remoteEntry.js', + exposes: moduleFederationConfig.exposes || {}, + remotes, + shared: getSharedConfig(moduleFederationConfig), + }), + ], + }; + }; +}; + +module.exports = withModuleFederation; diff --git a/playground/agentic-react-nx-module-federation-playground/nx.json b/playground/agentic-react-nx-module-federation-playground/nx.json new file mode 100644 index 0000000..b601bde --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/nx.json @@ -0,0 +1,33 @@ +{ + "npmScope": "mfplayground", + "affected": { + "defaultBase": "main" + }, + "workspaceLayout": { + "appsDir": "apps", + "libsDir": "libs" + }, + "tasksRunnerOptions": { + "default": { + "runner": "nx/tasks-runners/default", + "options": { + "cacheableOperations": ["build", "lint", "test"] + } + } + }, + "targetDefaults": { + "build": { + "dependsOn": ["^build"], + "inputs": ["production", "^production"] + } + }, + "namedInputs": { + "default": ["{projectRoot}/**/*", "sharedGlobals"], + "production": [ + "default", + "!{projectRoot}/**/*.spec.ts", + "!{projectRoot}/**/*.spec.tsx" + ], + "sharedGlobals": [] + } +} diff --git a/playground/agentic-react-nx-module-federation-playground/package.json b/playground/agentic-react-nx-module-federation-playground/package.json new file mode 100644 index 0000000..4178fa0 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/package.json @@ -0,0 +1,54 @@ +{ + "name": "agentic-react-nx-module-federation-playground", + "private": true, + "version": "0.0.0", + "scripts": { + "start": "node ./scripts/start-dev.mjs", + "start:shell": "nx run shell:serve", + "start:catalog": "nx run catalog:serve", + "start:profile": "nx run profile:serve", + "build": "nx run-many --target=build --projects=catalog,profile,shell --configuration=production", + "build:packages": "pnpm -C ../.. run build", + "test:e2e": "pnpm run build:packages && env -u NO_COLOR playwright test" + }, + "dependencies": { + "@chakra-ui/react": "2.10.9", + "@emotion/react": "catalog:", + "@emotion/styled": "catalog:", + "@heroicons/react": "^2.2.0", + "framer-motion": "^12.38.0", + "react": "18.2.0", + "react-17-compat": "npm:react@17.0.2", + "react-dom": "18.2.0", + "react-router-dom": "6.30.3", + "@agentic-react/webpack": "workspace:*" + }, + "devDependencies": { + "@babel/core": "7.20.2", + "@babel/preset-env": "7.20.2", + "@babel/preset-react": "7.18.6", + "@babel/preset-typescript": "7.18.6", + "@modelcontextprotocol/sdk": "1.29.0", + "@nrwl/devkit": "15.9.7", + "@nrwl/eslint-plugin-nx": "15.9.7", + "@nrwl/js": "15.9.7", + "@nrwl/react": "15.9.7", + "@nrwl/webpack": "15.9.7", + "@nrwl/workspace": "15.9.7", + "@playwright/test": "^1.59.1", + "@types/react": "18.3.23", + "@types/react-dom": "18.3.7", + "@typescript-eslint/eslint-plugin": "5.36.1", + "@typescript-eslint/parser": "5.36.1", + "babel-loader": "10.0.0", + "eslint": "8.23.1", + "html-webpack-plugin": "5.6.3", + "nx": "15.9.7", + "ts-loader": "9.4.1", + "typescript": "4.8.4", + "webpack": "5.107.1", + "webpack-cli": "6.0.1", + "webpack-dev-server": "5.2.4", + "webpack-merge": "5.8.0" + } +} diff --git a/playground/agentic-react-nx-module-federation-playground/playwright.config.mjs b/playground/agentic-react-nx-module-federation-playground/playwright.config.mjs new file mode 100644 index 0000000..2c24dc8 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/playwright.config.mjs @@ -0,0 +1,166 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from '@playwright/test'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const host = '127.0.0.1'; +const portStateKey = Buffer.from(__dirname).toString('base64url'); +const portStatePath = path.join( + os.tmpdir(), + `agentic-react-nx-mf-playground-${portStateKey}.json`, +); +const portLockPath = path.join( + os.tmpdir(), + `agentic-react-nx-mf-playground-${portStateKey}.lock`, +); + +const canListen = (port) => + new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); + +const findAvailablePort = async (preferredPort, reservedPorts) => { + for (let port = preferredPort; port < preferredPort + 1000; port += 1) { + if (reservedPorts.has(port)) { + continue; + } + + if (await canListen(port)) { + reservedPorts.add(port); + return port; + } + } + + throw new Error( + `Could not find an available Nx module federation playground port starting at ${preferredPort}.`, + ); +}; + +const readPortState = () => { + try { + const state = JSON.parse(fs.readFileSync(portStatePath, 'utf8')); + if (Date.now() - state.createdAt < 10 * 60 * 1000) { + return { + catalogPort: Number(state.catalogPort), + profilePort: Number(state.profilePort), + shellPort: Number(state.shellPort), + }; + } + } catch (_error) { + return null; + } + + return null; +}; + +const writePortState = ({ catalogPort, profilePort, shellPort }) => { + fs.mkdirSync(path.dirname(portStatePath), { recursive: true }); + fs.writeFileSync( + portStatePath, + JSON.stringify( + { catalogPort, createdAt: Date.now(), profilePort, shellPort }, + null, + 2, + ), + ); +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const waitForPortState = async () => { + for (let attempt = 0; attempt < 100; attempt += 1) { + const ports = readPortState(); + if (ports) { + return ports; + } + await sleep(100); + } + + throw new Error( + 'Timed out waiting for Nx module federation playground port allocation.', + ); +}; + +const allocatePorts = async () => { + const reservedPorts = new Set(); + const shellPort = await findAvailablePort( + Number(process.env.NX_MF_SHELL_PORT || 4200), + reservedPorts, + ); + const catalogPort = await findAvailablePort( + Number(process.env.NX_MF_CATALOG_PORT || 4201), + reservedPorts, + ); + const profilePort = await findAvailablePort( + Number(process.env.NX_MF_PROFILE_PORT || 4202), + reservedPorts, + ); + + return { catalogPort, profilePort, shellPort }; +}; + +const allocatePlaywrightPorts = async () => { + const existingPorts = readPortState(); + if (existingPorts) { + return existingPorts; + } + + fs.mkdirSync(path.dirname(portStatePath), { recursive: true }); + let lockHandle; + try { + lockHandle = fs.openSync(portLockPath, 'wx'); + } catch (_error) { + return await waitForPortState(); + } + + try { + const ports = await allocatePorts(); + writePortState(ports); + return ports; + } finally { + fs.closeSync(lockHandle); + fs.rmSync(portLockPath, { force: true }); + } +}; + +const ports = await allocatePlaywrightPorts(); +const { catalogPort, profilePort, shellPort } = ports; + +process.env.NX_MF_SHELL_PORT = String(shellPort); +process.env.NX_MF_CATALOG_PORT = String(catalogPort); +process.env.NX_MF_PROFILE_PORT = String(profilePort); +const shellUrl = `http://${host}:${shellPort}`; + +export default defineConfig({ + testDir: './e2e', + timeout: 45000, + expect: { + timeout: 10000, + }, + use: { + baseURL: shellUrl, + permissions: ['clipboard-read', 'clipboard-write'], + trace: 'on-first-retry', + }, + webServer: { + command: 'pnpm start', + cwd: __dirname, + env: { + ...process.env, + NX_MF_CATALOG_PORT: String(catalogPort), + NX_MF_PROFILE_PORT: String(profilePort), + NX_MF_SHELL_PORT: String(shellPort), + }, + url: shellUrl, + timeout: 240000, + reuseExistingServer: false, + }, +}); diff --git a/playground/agentic-react-nx-module-federation-playground/scripts/start-dev.mjs b/playground/agentic-react-nx-module-federation-playground/scripts/start-dev.mjs new file mode 100644 index 0000000..82503e6 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/scripts/start-dev.mjs @@ -0,0 +1,40 @@ +import { spawn } from 'node:child_process'; + +const projects = [ + ['catalog', process.env.NX_MF_CATALOG_PORT || '4201'], + ['profile', process.env.NX_MF_PROFILE_PORT || '4202'], + ['shell', process.env.NX_MF_SHELL_PORT || '4200'], +]; + +const children = projects.map(([project, port]) => + spawn('pnpm', ['exec', 'nx', 'run', `${project}:serve`, `--port=${port}`], { + env: process.env, + stdio: 'inherit', + }), +); + +const stopChildren = () => { + for (const child of children) { + if (!child.killed) { + child.kill('SIGTERM'); + } + } +}; + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + stopChildren(); + process.exit(0); + }); +} + +for (const child of children) { + child.on('exit', (code, signal) => { + if (code === 0 || signal === 'SIGTERM') { + return; + } + + stopChildren(); + process.exit(code || 1); + }); +} diff --git a/playground/agentic-react-nx-module-federation-playground/tsconfig.base.json b/playground/agentic-react-nx-module-federation-playground/tsconfig.base.json new file mode 100644 index 0000000..59309b4 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/tsconfig.base.json @@ -0,0 +1,30 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "rootDir": ".", + "sourceMap": true, + "declaration": false, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "es2015", + "module": "esnext", + "lib": ["dom", "dom.iterable", "es2020"], + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "baseUrl": ".", + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "paths": { + "@mfplayground/hearthhub-ui": [ + "libs/hearthhub-ui/src/index.ts" + ], + "@mfplayground/legacy-react-widget": [ + "libs/legacy-react-widget/src/index.ts" + ] + } + }, + "exclude": ["node_modules", "tmp"] +} diff --git a/playground/agentic-react-nx-module-federation-playground/webpack.config.base.js b/playground/agentic-react-nx-module-federation-playground/webpack.config.base.js new file mode 100644 index 0000000..5f40197 --- /dev/null +++ b/playground/agentic-react-nx-module-federation-playground/webpack.config.base.js @@ -0,0 +1,33 @@ +const webpack = require('webpack'); + +const allowedEnvPrefixes = ['REACT_APP_', 'NX_']; + +const getAllowedEnvironment = () => { + const definitions = { + 'process.env.NODE_ENV': JSON.stringify( + process.env.NODE_ENV || 'development', + ), + }; + + for (const [key, value] of Object.entries(process.env)) { + if (allowedEnvPrefixes.some((prefix) => key.startsWith(prefix))) { + definitions[`process.env.${key}`] = JSON.stringify(value); + } + } + + return definitions; +}; + +const replaceDefinePlugin = (config) => ({ + ...config, + plugins: [ + ...(config.plugins || []).filter( + (plugin) => plugin?.constructor?.name !== 'DefinePlugin', + ), + new webpack.DefinePlugin(getAllowedEnvironment()), + ], +}); + +module.exports = { + replaceDefinePlugin, +}; diff --git a/playground/user-profile-app/.gitignore b/playground/agentic-react-vite-playground/.gitignore similarity index 90% rename from playground/user-profile-app/.gitignore rename to playground/agentic-react-vite-playground/.gitignore index 4d29575..e7aaa32 100644 --- a/playground/user-profile-app/.gitignore +++ b/playground/agentic-react-vite-playground/.gitignore @@ -7,6 +7,8 @@ # testing /coverage +/playwright-report +/test-results # production /build diff --git a/playground/agentic-react-vite-playground/e2e/selection-context.spec.js b/playground/agentic-react-vite-playground/e2e/selection-context.spec.js new file mode 100644 index 0000000..e206fed --- /dev/null +++ b/playground/agentic-react-vite-playground/e2e/selection-context.spec.js @@ -0,0 +1,981 @@ +import { expect, test } from '@playwright/test'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +const MCP_SERVER_URL = 'http://127.0.0.1:51423/mcp'; + +const createMcpClient = async () => { + const client = new Client({ + name: 'agentic-react-e2e', + version: '0.0.0', + }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL)); + await client.connect(transport); + return { client, transport }; +}; + +const parseToolResponse = (result) => { + const textContent = result.content.find((content) => content.type === 'text'); + if (!textContent || typeof textContent.text !== 'string') { + return null; + } + return JSON.parse(textContent.text); +}; + +const getToolText = (result) => { + const textContent = result.content.find((content) => content.type === 'text'); + if (!textContent || typeof textContent.text !== 'string') { + return ''; + } + return textContent.text; +}; + +const selectProfileEmailField = async (page) => { + await page.goto('/profile/1'); + await page.getByRole('button', { name: 'Edit Profile', exact: true }).click(); + + await page.waitForFunction(() => window.__AGENTIC_REACT__); + await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + await page.locator('#profile-field-email').click(); + + const didCapture = await page + .waitForFunction( + () => Boolean(window.__AGENTIC_REACT__?.getLastSelectionContext()), + null, + { timeout: 1000 }, + ) + .then(() => true) + .catch(() => false); + if (!didCapture) { + await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + await page.locator('#profile-field-email').click(); + await page.waitForFunction(() => + Boolean(window.__AGENTIC_REACT__?.getLastSelectionContext()), + ); + } +}; + +const getToolkitRoot = (page) => + page.locator('[data-agentic-react-toolkit="true"]'); + +const openToolkitPanel = async (page) => { + const toolkitRoot = getToolkitRoot(page); + await toolkitRoot.waitFor(); + const selectButton = toolkitRoot.getByRole('button', { + name: 'Select', + exact: true, + }); + if (await selectButton.isVisible()) { + return; + } + + const launcherButton = toolkitRoot.locator( + 'button[aria-label*="Agentic React toolkit"]', + ); + + await launcherButton.first().click({ force: true }); + + if (!(await selectButton.isVisible())) { + await launcherButton.first().click({ force: true }); + } + + await expect(selectButton).toBeVisible(); +}; + +const captureWithToolkitUi = async (page, selector) => { + const toolkitRoot = getToolkitRoot(page); + await openToolkitPanel(page); + await toolkitRoot.getByRole('button', { name: 'Select', exact: true }).click(); + await page.locator(selector).click(); + await page.waitForFunction( + (selectedSelector) => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + selectedSelector, + selector, + ); + await expect( + toolkitRoot.getByText('Captured', { exact: false }), + ).toBeVisible(); +}; + +test('copying without a selection returns a no-selection response', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const copyResult = await page.evaluate(() => + window.__AGENTIC_REACT__.copyLastSelectionContext('json'), + ); + + expect(copyResult.success).toBe(false); + expect(copyResult.error).toBe('No selection context found'); +}); + +test('selecting a profile field captures its React source context', async ({ + page, +}) => { + await selectProfileEmailField(page); + + const context = await page.waitForFunction(() => { + const selectionContext = + window.__AGENTIC_REACT__?.getLastSelectionContext(); + if (!selectionContext) return null; + return selectionContext; + }); + const selectionContext = await context.jsonValue(); + + expect(selectionContext.componentName).toBe('ProfileField'); + expect(selectionContext.selector).toBe('#profile-field-email'); + expect(selectionContext.resolvedSources).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining( + 'src/components/UserProfile/ProfileField.jsx', + ), + }), + ); +}); + +test('toolkit select copies the selected context automatically', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.getByRole('button', { name: 'Edit Profile', exact: true }).click(); + + await captureWithToolkitUi(page, '#profile-field-email'); + + const clipboardText = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(clipboardText).toContain('component: ProfileField'); + expect(clipboardText).toContain('selector: #profile-field-email'); + expect(clipboardText).toContain( + '', + ); + expect(clipboardText).toContain(''); + expect(clipboardText).toContain('source: '); + expect(clipboardText).toContain( + '/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileField.jsx', + ); +}); + +test('toolkit multiselect appends selections and copies all on done', async ({ + page, +}, testInfo) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const toolkitRoot = getToolkitRoot(page); + await openToolkitPanel(page); + await expect( + toolkitRoot.getByRole('button', { name: 'Copy', exact: true }), + ).toHaveCount(0); + + await toolkitRoot + .getByRole('button', { name: 'Multiselect', exact: true }) + .click(); + const doneButton = toolkitRoot.getByRole('button', { + name: 'Done', + exact: true, + }); + const clearAllButton = toolkitRoot.getByRole('button', { + name: 'Clear all selections', + exact: true, + }); + await expect(doneButton).toBeVisible(); + await expect(clearAllButton).toBeVisible(); + await expect(clearAllButton).toContainText('Clear all'); + await expect(clearAllButton).toBeDisabled(); + + const doneStyles = await doneButton.evaluate((element) => { + const styles = window.getComputedStyle(element); + return { + backgroundColor: styles.backgroundColor, + color: styles.color, + }; + }); + expect(doneStyles.backgroundColor).toBe('rgb(220, 38, 38)'); + expect(doneStyles.color).toBe('rgb(255, 255, 255)'); + + await page.locator('#profile-display-email-value').click(); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '#profile-display-email-value', + ); + await expect( + page.locator('[data-agentic-react-multi-selected="true"]'), + ).toHaveCount(1); + await page.locator('#profile-header-occupation-value').click(); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '#profile-header-occupation-value', + ); + await expect( + page.locator('[data-agentic-react-multi-selected="true"]'), + ).toHaveCount(2); + await expect(clearAllButton).toBeEnabled(); + + await clearAllButton.click(); + await expect( + page.locator('[data-agentic-react-multi-selected="true"]'), + ).toHaveCount(0); + await expect(clearAllButton).toBeDisabled(); + await expect(toolkitRoot).toContainText('Cleared all selections'); + + await page.locator('#profile-display-email-value').click(); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '#profile-display-email-value', + ); + await page.locator('#profile-header-occupation-value').click(); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '#profile-header-occupation-value', + ); + await expect( + page.locator('[data-agentic-react-multi-selected="true"]'), + ).toHaveCount(2); + + const multiSelectedOverlays = page.locator( + '[data-agentic-react-multi-selected="true"]', + ); + const firstTuneButton = multiSelectedOverlays + .first() + .getByRole('button', { name: 'Adjust selection', exact: true }); + await firstTuneButton.click({ force: true }); + const tuningModal = page.locator('[data-agentic-react-tuning-modal="true"]'); + const tuningSurface = page.locator( + '[data-agentic-react-tuning-surface="true"]', + ); + const tuningPanel = page.locator('[data-agentic-react-tuning-panel="true"]'); + await expect(tuningModal.getByLabel('Text color')).toBeVisible(); + await expect(tuningModal.getByLabel('Background')).toBeVisible(); + await expect(tuningModal.getByLabel('Opacity', { exact: true })).toBeVisible(); + await expect( + tuningModal.getByLabel('Font', { exact: true }), + ).toHaveCount(1); + await expect( + tuningModal.getByLabel('Font size', { exact: true }), + ).toHaveCount(1); + await expect( + tuningModal.getByLabel('Font weight', { exact: true }), + ).toHaveCount(1); + await expect(tuningModal.getByLabel('Width', { exact: true })).toHaveCount(1); + await expect(tuningModal.getByLabel('Height', { exact: true })).toHaveCount(1); + await expect(tuningModal.getByLabel('Padding', { exact: true })).toHaveCount(1); + await expect(tuningModal.getByLabel('Margin', { exact: true })).toHaveCount(1); + await expect( + tuningModal.getByLabel('Describe custom tuning changes'), + ).toBeVisible(); + await expect(tuningSurface).toHaveClass(/vite-playground-tuning-surface/); + await expect(tuningPanel).toBeVisible(); + await expect(tuningPanel).toHaveClass(/vite-playground-tuning-panel/); + await expect(tuningModal.getByLabel('Text color')).toHaveClass( + /vite-playground-tuning-control/, + ); + const tuningStyleOverrides = await tuningPanel.evaluate((element) => { + const styles = window.getComputedStyle(element); + const targetTagElement = element.querySelector( + '[data-agentic-react-tuning-target-tag="true"]', + ); + const targetTagStyles = targetTagElement + ? window.getComputedStyle(targetTagElement) + : null; + return { + borderRadius: styles.borderRadius, + borderTopColor: styles.borderTopColor, + maxHeight: styles.maxHeight, + overflowY: styles.overflowY, + targetTagBackground: targetTagStyles?.backgroundColor || '', + targetTagColor: targetTagStyles?.color || '', + }; + }); + expect(tuningStyleOverrides.borderRadius).toBe('14px'); + expect(tuningStyleOverrides.borderTopColor).toBe( + 'rgba(15, 118, 110, 0.22)', + ); + expect(tuningStyleOverrides.maxHeight).toBe('360px'); + expect(tuningStyleOverrides.overflowY).toBe('auto'); + expect(tuningStyleOverrides.targetTagBackground).toBe('rgb(236, 254, 255)'); + expect(tuningStyleOverrides.targetTagColor).toBe('rgb(15, 118, 110)'); + await expect( + tuningModal + .locator('[data-agentic-react-color-value="true"]') + .filter({ hasText: 'rgba(' }) + .first(), + ).toBeVisible(); + const tuningPanelPlacement = await tuningPanel.evaluate((element) => { + const panelRect = element.getBoundingClientRect(); + const anchorRect = document + .querySelector( + '[data-agentic-react-multi-selected="true"] [aria-label="Adjust selection"]', + ) + ?.getBoundingClientRect(); + + return { + panel: { + left: panelRect.left, + top: panelRect.top, + right: panelRect.right, + bottom: panelRect.bottom, + }, + anchor: anchorRect + ? { + left: anchorRect.left, + right: anchorRect.right, + } + : null, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + }, + }; + }); + expect(tuningPanelPlacement.panel.left).toBeGreaterThanOrEqual(-0.5); + expect(tuningPanelPlacement.panel.top).toBeGreaterThanOrEqual(-0.5); + expect(tuningPanelPlacement.panel.right).toBeLessThanOrEqual( + tuningPanelPlacement.viewport.width + 0.5, + ); + expect(tuningPanelPlacement.panel.bottom).toBeLessThanOrEqual( + tuningPanelPlacement.viewport.height + 0.5, + ); + if (!tuningPanelPlacement.anchor) { + throw new Error('Expected tuning panel to be anchored to the tune button'); + } + expect( + Math.min( + Math.abs( + tuningPanelPlacement.panel.left - tuningPanelPlacement.anchor.right, + ), + Math.abs( + tuningPanelPlacement.anchor.left - tuningPanelPlacement.panel.right, + ), + ), + ).toBeLessThanOrEqual(16); + const tuningScreenshotPath = + process.env.AGENTIC_REACT_TUNING_SCREENSHOT_PATH; + const tuningScreenshot = await page.screenshot({ + ...(tuningScreenshotPath ? { path: tuningScreenshotPath } : {}), + fullPage: false, + }); + await testInfo.attach('tuning-modal-positioning', { + body: tuningScreenshot, + contentType: 'image/png', + }); + const textColorInput = tuningModal.locator('input[name="text-color"]'); + await textColorInput.fill('#336699'); + await textColorInput.dispatchEvent('change'); + await expect(toolkitRoot).toContainText('rgb(51, 102, 153)'); + await expect( + tuningModal.locator('[data-agentic-react-color-value="true"]').first(), + ).toContainText('rgba(51, 102, 153, 1)'); + const opacityInput = tuningModal.locator('input[name="opacity"]'); + await opacityInput.fill('0.8'); + await opacityInput.dispatchEvent('change'); + await expect(toolkitRoot).toContainText('opacity to 0.8'); + await tuningModal + .getByRole('button', { name: 'Increment Font size', exact: true }) + .click(); + await expect(toolkitRoot).toContainText('font size to 17px'); + await tuningPanel.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await tuningModal.locator('select[name="font-weight"]').selectOption('700'); + await expect(toolkitRoot).toContainText('font weight to 700'); + const paddingInput = tuningModal.locator('input[name="padding"]'); + await paddingInput.fill('12'); + await paddingInput.dispatchEvent('change'); + await expect(toolkitRoot).toContainText('padding to 12px'); + await tuningModal + .getByRole('button', { name: 'Close tuning options', exact: true }) + .click(); + + await multiSelectedOverlays + .nth(1) + .getByRole('button', { name: 'Delete selection', exact: true }) + .click({ force: true }); + await expect(multiSelectedOverlays).toHaveCount(1); + + await doneButton.click(); + await expect( + page.locator('[data-agentic-react-multi-selected="true"]'), + ).toHaveCount(0); + + const clipboardText = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(clipboardText).toContain('Selection 1'); + expect(clipboardText).toContain( + '', + ); + expect(clipboardText).toContain(''); + expect(clipboardText).toContain('selector: #profile-display-email-value'); + expect(clipboardText).toContain('tuning prompts:'); + expect(clipboardText).toContain('text color to rgb(51, 102, 153).'); + expect(clipboardText).toContain('opacity to 0.8.'); + expect(clipboardText).toContain('font size to 17px.'); + expect(clipboardText).toContain('font weight to 700.'); + expect(clipboardText).toContain('padding to 12px.'); + expect(clipboardText).not.toContain('Selection 2'); + expect(clipboardText).not.toContain( + 'selector: #profile-header-occupation-value', + ); +}); + +test('copying the selected context includes a playground source snippet', async ({ + page, +}) => { + await selectProfileEmailField(page); + await page.waitForFunction(() => + window.__AGENTIC_REACT__?.getLastSelectionContext(), + ); + + const copyResult = await page.evaluate(() => + window.__AGENTIC_REACT__.copyLastSelectionContext('json'), + ); + + expect(copyResult.success).toBe(true); + expect(copyResult.context.sourceSnippets).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining( + 'src/components/UserProfile/ProfileField.jsx', + ), + snippet: expect.stringContaining('profile-field-${name}'), + }), + ); + expect(copyResult.context.sourcePreview).toContain('profile-field-${name}'); + expect(copyResult.context.sourcePreview).toContain('source trace:'); + expect(copyResult.context.sourcePreview).toContain( + 'ProfileField at src/components/UserProfile/ProfileField.jsx', + ); + expect(copyResult.context.sourcePreview).toContain( + 'src/components/UserProfile/ProfileContent.jsx', + ); + expect( + copyResult.context.sourceSnippets.map(({ filePath }) => filePath).join('\n'), + ).not.toContain('node_modules'); +}); + +test('toolkit tuning exposes layout controls for container elements', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + await page.evaluate(() => { + const fixtureElement = document.createElement('div'); + fixtureElement.id = 'layout-tuning-fixture'; + fixtureElement.style.display = 'flex'; + fixtureElement.style.alignItems = 'center'; + fixtureElement.style.justifyContent = 'space-between'; + fixtureElement.style.gap = '10px'; + fixtureElement.style.margin = '12px'; + fixtureElement.style.padding = '12px'; + fixtureElement.innerHTML = + 'Layout fixture'; + document.body.prepend(fixtureElement); + }); + + const toolkitRoot = getToolkitRoot(page); + await openToolkitPanel(page); + await toolkitRoot.getByRole('button', { name: 'Select', exact: true }).click(); + await page.locator('#layout-tuning-fixture').click(); + await page.waitForFunction(() => + window.__AGENTIC_REACT__?.getLastSelectionContext(), + ); + + await page + .locator('[data-agentic-react-selected="true"]') + .getByRole('button', { name: 'Adjust selection', exact: true }) + .click({ force: true }); + + const tuningModal = page.locator('[data-agentic-react-tuning-modal="true"]'); + await expect( + tuningModal.getByLabel('Layout direction', { exact: true }), + ).toBeVisible(); + await expect( + tuningModal.getByLabel('Distribution', { exact: true }), + ).toBeVisible(); + await expect( + tuningModal.getByLabel('Alignment', { exact: true }), + ).toBeVisible(); + await expect(tuningModal.getByLabel('Spacing', { exact: true })).toBeVisible(); + + await tuningModal + .locator('select[name="layout-direction"]') + .selectOption('column'); + await expect(toolkitRoot).toContainText('layout direction to column'); +}); + +test('toolkit tuning modal extensions can wrap and add modal content', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + await page.evaluate(() => { + window.__AGENTIC_REACT__.registerTuningModalExtension({ + id: 'e2e-custom-tuning-modal', + beforeFields({ container, context }) { + const element = document.createElement('div'); + element.setAttribute('data-e2e-tuning-before', 'true'); + element.textContent = `Custom before ${context.tagName}`; + container.appendChild(element); + }, + afterFields({ container, actions }) { + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('data-e2e-tuning-prompt', 'true'); + button.textContent = 'Add glow prompt'; + button.addEventListener('click', () => { + actions.addPrompt('Make the selected element glow.'); + }); + container.appendChild(button); + }, + footer({ container }) { + const element = document.createElement('div'); + element.setAttribute('data-e2e-tuning-footer', 'true'); + element.textContent = 'Custom footer'; + container.appendChild(element); + }, + wrapModal({ surfaceElement, panelElement }) { + const shell = document.createElement('div'); + shell.setAttribute('data-e2e-tuning-wrapper', 'true'); + shell.style.border = '2px solid rgb(14, 165, 233)'; + shell.style.padding = '4px'; + shell.style.background = 'rgb(240, 249, 255)'; + surfaceElement.replaceChildren(shell); + shell.appendChild(panelElement); + + return () => { + surfaceElement.replaceChildren(panelElement); + }; + }, + }); + }); + + const toolkitRoot = getToolkitRoot(page); + await openToolkitPanel(page); + await toolkitRoot.getByRole('button', { name: 'Select', exact: true }).click(); + await page.locator('#profile-display-email-value').click(); + await page.waitForFunction(() => + window.__AGENTIC_REACT__?.getLastSelectionContext(), + ); + + await page + .locator('[data-agentic-react-selected="true"]') + .getByRole('button', { name: 'Adjust selection', exact: true }) + .click({ force: true }); + + const tuningModal = page.locator('[data-agentic-react-tuning-modal="true"]'); + const tuningSurface = page.locator( + '[data-agentic-react-tuning-surface="true"]', + ); + const tuningPanel = page.locator('[data-agentic-react-tuning-panel="true"]'); + await expect( + tuningSurface.locator('[data-e2e-tuning-wrapper="true"]'), + ).toBeVisible(); + await expect( + tuningModal.locator('[data-e2e-tuning-before="true"]'), + ).toContainText('Custom before'); + await expect( + tuningModal.locator('[data-e2e-tuning-footer="true"]'), + ).toContainText('Custom footer'); + await expect(tuningModal.getByLabel('Text color')).toBeVisible(); + await expect(tuningModal.getByLabel('Opacity', { exact: true })).toBeVisible(); + await expect(tuningModal.getByLabel('Width', { exact: true })).toBeVisible(); + + const surfacePlacement = await tuningSurface.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }; + }); + expect(surfacePlacement.left).toBeGreaterThanOrEqual(-0.5); + expect(surfacePlacement.top).toBeGreaterThanOrEqual(-0.5); + expect(surfacePlacement.right).toBeLessThanOrEqual( + surfacePlacement.viewportWidth + 0.5, + ); + expect(surfacePlacement.bottom).toBeLessThanOrEqual( + surfacePlacement.viewportHeight + 0.5, + ); + + await tuningModal + .locator('[data-e2e-tuning-prompt="true"]') + .click({ force: true }); + await expect(toolkitRoot).toContainText('Make the selected element glow.'); + await expect(tuningPanel).toBeVisible(); +}); + +test('selecting an element without an id still returns a usable fallback selector', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.getByRole('button', { name: 'Edit Profile', exact: true }).click(); + await expect(page.locator('#profile-field-email')).toBeVisible(); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + await page.evaluate(() => window.__AGENTIC_REACT__.enterSelectionMode()); + await page.locator('label', { hasText: 'Email' }).first().click(); + + const context = await page.waitForFunction(() => + window.__AGENTIC_REACT__?.getLastSelectionContext(), + ); + const selectionContext = await context.jsonValue(); + + expect(selectionContext.componentName).toBe('ProfileField'); + expect(selectionContext.selector).toBeTruthy(); + + const selectorMatchesSelection = await page.evaluate((selector) => { + if (!selector) return false; + return document.querySelector(selector) !== null; + }, selectionContext.selector); + + expect(selectorMatchesSelection).toBe(true); +}); + +test('copying root-relative source context does not request filesystem /src fallback', async ({ + page, +}) => { + let requestedAbsoluteSrcFallback = false; + + await selectProfileEmailField(page); + await page.waitForFunction(() => + window.__AGENTIC_REACT__?.getLastSelectionContext(), + ); + await page.route('**/src/components/UserProfile/ProfileField.jsx**', (route) => + route.fulfill({ status: 404, body: '' }), + ); + await page.route( + '**/@fs/src/components/UserProfile/ProfileField.jsx**', + (route) => { + requestedAbsoluteSrcFallback = true; + return route.fulfill({ status: 404, body: '' }); + }, + ); + + const copyResult = await page.evaluate(() => + window.__AGENTIC_REACT__.copyLastSelectionContext('json'), + ); + + expect(copyResult.success).toBe(true); + expect(requestedAbsoluteSrcFallback).toBe(false); +}); + +test('MCP tools return no-selection and selected-context responses', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const { client, transport } = await createMcpClient(); + let transportClosed = false; + try { + const noSelectionRaw = await client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + }, + }); + const noSelection = parseToolResponse(noSelectionRaw); + expect(noSelection.success).toBe(false); + expect(noSelection.context).toBeNull(); + expect(noSelection.message).toContain('No selection context'); + + await transport.close(); + transportClosed = true; + + await selectProfileEmailField(page); + await page.waitForFunction(() => + window.__AGENTIC_REACT__?.getLastSelectionContext(), + ); + + const selectionMcp = await createMcpClient(); + try { + const selectedContextRaw = await selectionMcp.client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 3, + maxFiles: 2, + }, + }); + const selectedContext = parseToolResponse(selectedContextRaw); + expect(selectedContext.success).toBe(true); + expect(selectedContext.context.componentName).toBe('ProfileField'); + expect(selectedContext.context.sourceSnippets.length).toBeGreaterThan(0); + expect(selectedContext.summary).toContain('ProfileField'); + } finally { + await selectionMcp.transport.close(); + } + } finally { + if (!transportClosed) { + await transport.close(); + } + } +}); + +test('MCP get-html-elements and get-react-source-code return deterministic matches and source context', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.getByRole('button', { name: 'Edit Profile', exact: true }).click(); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const { client, transport } = await createMcpClient(); + try { + const htmlMatchesRaw = await client.callTool({ + name: 'get-html-elements', + arguments: { + queries: ['profile-field-email'], + maxMatches: 3, + }, + }); + const htmlMatches = parseToolResponse(htmlMatchesRaw); + + expect(htmlMatches.success).toBe(true); + expect(htmlMatches.count).toBeGreaterThan(0); + expect(htmlMatches.matches[0].selector).toBe('#profile-field-email'); + + const reactSourceRaw = await client.callTool({ + name: 'get-react-source-code', + arguments: { + queries: ['profile-field-email'], + maxMatches: 3, + includeSourceSnippets: true, + contextLines: 4, + maxFiles: 3, + }, + }); + const reactSource = parseToolResponse(reactSourceRaw); + + expect(reactSource.success).toBe(true); + expect(reactSource.chosenMatch.selector).toBe('#profile-field-email'); + expect(reactSource.context.componentName).toBe('ProfileField'); + expect(reactSource.context.sourceSnippets).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining( + 'src/components/UserProfile/ProfileField.jsx', + ), + }), + ); + expect(reactSource.summary).toContain('ProfileField'); + } finally { + await transport.close(); + } +}); + +test('MCP get-react-source-code returns no-match payload when queries miss all elements', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const { client, transport } = await createMcpClient(); + try { + const missRaw = await client.callTool({ + name: 'get-react-source-code', + arguments: { + queries: ['this-does-not-exist-anywhere'], + }, + }); + const miss = parseToolResponse(missRaw); + expect(miss.success).toBe(false); + expect(miss.reason).toContain('No element matched'); + expect(miss.context).toBeNull(); + } finally { + await transport.close(); + } +}); + +test('toolkit bottom-right UI can capture reusable component source context across parents', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const toolkitRoot = getToolkitRoot(page); + const toolkitPosition = await toolkitRoot.evaluate((element) => { + const styles = window.getComputedStyle(element); + return { + position: styles.position, + right: styles.right, + bottom: styles.bottom, + }; + }); + expect(toolkitPosition.position).toBe('fixed'); + expect(toolkitPosition.right).toBe('20px'); + expect(toolkitPosition.bottom).toBe('20px'); + + await captureWithToolkitUi(page, '#profile-display-email-value'); + + const profileFieldMcp = await createMcpClient(); + try { + const profileFieldContextRaw = await profileFieldMcp.client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 4, + maxFiles: 4, + }, + }); + const profileFieldContext = parseToolResponse(profileFieldContextRaw); + expect(profileFieldContext.success).toBe(true); + expect(profileFieldContext.context.selector).toBe('#profile-display-email-value'); + expect(profileFieldContext.context.resolvedSources).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining('src/components/Common/LabeledValue.jsx'), + }), + ); + expect(profileFieldContext.context.resolvedSources).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining('src/components/UserProfile/ProfileField.jsx'), + }), + ); + } finally { + await profileFieldMcp.transport.close(); + } + + await captureWithToolkitUi(page, '#profile-header-occupation-value'); + + const profileHeaderMcp = await createMcpClient(); + try { + const profileHeaderContextRaw = await profileHeaderMcp.client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 4, + maxFiles: 4, + }, + }); + const profileHeaderContext = parseToolResponse(profileHeaderContextRaw); + expect(profileHeaderContext.success).toBe(true); + expect(profileHeaderContext.context.selector).toBe( + '#profile-header-occupation-value', + ); + expect(profileHeaderContext.context.resolvedSources).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining('src/components/Common/LabeledValue.jsx'), + }), + ); + expect(profileHeaderContext.context.resolvedSources).toContainEqual( + expect.objectContaining({ + filePath: expect.stringContaining('src/components/UserProfile/ProfileHeader.jsx'), + }), + ); + } finally { + await profileHeaderMcp.transport.close(); + } +}); + +test('MCP built-in tooling endpoints all return expected outcomes', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const { client, transport } = await createMcpClient(); + let transportClosed = false; + try { + const highlightRaw = await client.callTool({ + name: 'highlight-component', + arguments: { componentName: 'ProfileField' }, + }); + const highlightText = getToolText(highlightRaw); + expect(highlightText).toContain('highlighted'); + + const treeRaw = await client.callTool({ + name: 'get-component-tree', + arguments: { allComponents: false }, + }); + const treeText = getToolText(treeRaw); + const tree = JSON.parse(treeText); + expect(tree).toBeTruthy(); + expect(JSON.stringify(tree)).toContain('UserProfile'); + + const statesRaw = await client.callTool({ + name: 'get-component-states', + arguments: { componentName: 'ProfileField' }, + }); + const statesText = getToolText(statesRaw); + const states = JSON.parse(statesText); + expect(states).toBeTruthy(); + expect(Object.keys(states).length).toBeGreaterThan(0); + + const rerendersRaw = await client.callTool({ + name: 'get-unnecessary-rerenders', + arguments: { timeframe: 60, allComponents: true }, + }); + const rerendersText = getToolText(rerendersRaw); + const rerenders = JSON.parse(rerendersText); + expect(Array.isArray(rerenders)).toBe(true); + + await transport.close(); + transportClosed = true; + + await page.getByRole('button', { name: 'Edit Profile', exact: true }).click(); + await expect(page.locator('#profile-field-email')).toBeVisible(); + await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + await page.locator('#profile-field-email').click(); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '#profile-field-email', + ); + const selectionMcp = await createMcpClient(); + try { + const copiedRaw = await selectionMcp.client.callTool({ + name: 'copy-last-selection-context', + arguments: { format: 'json' }, + }); + const copied = parseToolResponse(copiedRaw); + expect(typeof copied.success).toBe('boolean'); + expect(typeof copied.copied).toBe('boolean'); + expect(copied.format).toBe('json'); + expect(copied.context).toBeTruthy(); + if (!copied.success) { + expect(copied.error).toContain('Failed to copy context'); + } + } finally { + await selectionMcp.transport.close(); + } + } finally { + if (!transportClosed) { + await transport.close(); + } + } +}); + +test('MCP custom tools execute inside the browser runtime', async ({ page }) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const { client, transport } = await createMcpClient(); + try { + const customToolRaw = await client.callTool({ + name: 'log1', + arguments: { message: 'browser-runtime-check' }, + }); + const customToolResult = parseToolResponse(customToolRaw); + + expect(customToolResult.success).toBe(true); + expect(customToolResult.runtime).toBe('browser'); + expect(customToolResult.message).toBe( + 'Log1 received: browser-runtime-check', + ); + + const browserCalls = await page.evaluate( + () => window.__AGENTIC_REACT_CUSTOM_TOOL_CALLS__ || [], + ); + expect(browserCalls).toContain('browser-runtime-check'); + } finally { + await transport.close(); + } +}); diff --git a/playground/user-profile-app/index.html b/playground/agentic-react-vite-playground/index.html similarity index 100% rename from playground/user-profile-app/index.html rename to playground/agentic-react-vite-playground/index.html diff --git a/playground/user-profile-app/package.json b/playground/agentic-react-vite-playground/package.json similarity index 79% rename from playground/user-profile-app/package.json rename to playground/agentic-react-vite-playground/package.json index cecc694..a29c3a7 100644 --- a/playground/user-profile-app/package.json +++ b/playground/agentic-react-vite-playground/package.json @@ -1,9 +1,11 @@ { - "name": "user-profile-app", + "name": "agentic-react-vite-playground", "type": "module", "scripts": { "start": "vite", "dev": "vite", + "e2e": "env -u NO_COLOR playwright test", + "test:e2e": "pnpm -C ../.. run build && pnpm run e2e", "build": "vite build", "preview": "vite preview" }, @@ -22,11 +24,12 @@ }, "devDependencies": { "@babel/preset-react": "^7.26.3", + "@playwright/test": "^1.59.1", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "^4.3.4", - "vite": "^6.2.3", - "vite-react-mcp": "workspace:*" + "vite": "^6.4.2", + "@agentic-react/vite": "workspace:*" }, "browserslist": { "production": [ diff --git a/playground/agentic-react-vite-playground/playwright.config.js b/playground/agentic-react-vite-playground/playwright.config.js new file mode 100644 index 0000000..9af2921 --- /dev/null +++ b/playground/agentic-react-vite-playground/playwright.config.js @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30000, + expect: { + timeout: 5000, + }, + use: { + baseURL: 'http://127.0.0.1:51423', + permissions: ['clipboard-read', 'clipboard-write'], + trace: 'retain-on-failure', + }, + webServer: { + command: 'pnpm dev --host 127.0.0.1', + url: 'http://127.0.0.1:51423', + reuseExistingServer: false, + timeout: 30000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/playground/agentic-react-vite-playground/public/agentic-react-logo.png b/playground/agentic-react-vite-playground/public/agentic-react-logo.png new file mode 100644 index 0000000..029bf21 Binary files /dev/null and b/playground/agentic-react-vite-playground/public/agentic-react-logo.png differ diff --git a/playground/user-profile-app/public/favicon.ico b/playground/agentic-react-vite-playground/public/favicon.ico similarity index 100% rename from playground/user-profile-app/public/favicon.ico rename to playground/agentic-react-vite-playground/public/favicon.ico diff --git a/playground/agentic-react-vite-playground/public/generated/customer-operations-directory.png b/playground/agentic-react-vite-playground/public/generated/customer-operations-directory.png new file mode 100644 index 0000000..1363402 Binary files /dev/null and b/playground/agentic-react-vite-playground/public/generated/customer-operations-directory.png differ diff --git a/playground/user-profile-app/public/index.html b/playground/agentic-react-vite-playground/public/index.html similarity index 100% rename from playground/user-profile-app/public/index.html rename to playground/agentic-react-vite-playground/public/index.html diff --git a/playground/user-profile-app/public/logo192.png b/playground/agentic-react-vite-playground/public/logo192.png similarity index 100% rename from playground/user-profile-app/public/logo192.png rename to playground/agentic-react-vite-playground/public/logo192.png diff --git a/playground/user-profile-app/public/logo512.png b/playground/agentic-react-vite-playground/public/logo512.png similarity index 100% rename from playground/user-profile-app/public/logo512.png rename to playground/agentic-react-vite-playground/public/logo512.png diff --git a/playground/user-profile-app/public/manifest.json b/playground/agentic-react-vite-playground/public/manifest.json similarity index 100% rename from playground/user-profile-app/public/manifest.json rename to playground/agentic-react-vite-playground/public/manifest.json diff --git a/playground/user-profile-app/public/robots.txt b/playground/agentic-react-vite-playground/public/robots.txt similarity index 100% rename from playground/user-profile-app/public/robots.txt rename to playground/agentic-react-vite-playground/public/robots.txt diff --git a/playground/user-profile-app/src/App.css b/playground/agentic-react-vite-playground/src/App.css similarity index 76% rename from playground/user-profile-app/src/App.css rename to playground/agentic-react-vite-playground/src/App.css index 78dbf57..46ca728 100644 --- a/playground/user-profile-app/src/App.css +++ b/playground/agentic-react-vite-playground/src/App.css @@ -1,23 +1,16 @@ .App { min-height: 100vh; - background-color: #f5f5f5; - padding: 20px 0; + background-color: #f8fafc; } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -@media (max-width: 600px) { - .App { - padding: 10px 0; - } -} - .App-logo { height: 40vmin; pointer-events: none; diff --git a/playground/agentic-react-vite-playground/src/App.jsx b/playground/agentic-react-vite-playground/src/App.jsx new file mode 100644 index 0000000..292c32a --- /dev/null +++ b/playground/agentic-react-vite-playground/src/App.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import './App.css'; +import { UserProfile, UserList } from './components'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import { UserProvider } from './context/UserContext'; + +const theme = createTheme({ + shape: { + borderRadius: 8, + }, + palette: { + background: { + default: '#f8fafc', + paper: '#ffffff', + }, + primary: { + main: '#0f766e', + }, + secondary: { + main: '#7c3aed', + }, + success: { + main: '#15803d', + }, + warning: { + main: '#b45309', + }, + }, + typography: { + fontFamily: + 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + h3: { + fontSize: 'clamp(2rem, 4vw, 3.2rem)', + fontWeight: 800, + letterSpacing: 0, + lineHeight: 1, + }, + h4: { + fontWeight: 800, + letterSpacing: 0, + }, + h5: { + fontWeight: 800, + letterSpacing: 0, + }, + h6: { + fontWeight: 800, + letterSpacing: 0, + }, + button: { + fontWeight: 800, + letterSpacing: 0, + textTransform: 'none', + }, + }, + components: { + MuiButton: { + styleOverrides: { + root: { + minHeight: 40, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + borderRadius: 8, + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + }, +}); + +const App = function () { + return ( + + + + +
+ + } /> + } /> + } /> + } /> + +
+
+
+
+ ); +} + +export default App; +// For babel test diff --git a/playground/user-profile-app/src/App.test.js b/playground/agentic-react-vite-playground/src/App.test.js similarity index 100% rename from playground/user-profile-app/src/App.test.js rename to playground/agentic-react-vite-playground/src/App.test.js diff --git a/playground/agentic-react-vite-playground/src/components/Common/LabeledValue.jsx b/playground/agentic-react-vite-playground/src/components/Common/LabeledValue.jsx new file mode 100644 index 0000000..614566a --- /dev/null +++ b/playground/agentic-react-vite-playground/src/components/Common/LabeledValue.jsx @@ -0,0 +1,45 @@ +import React, { memo } from 'react'; + +const LabeledValue = memo( + ({ + label, + value, + idBase = null, + containerStyle = {}, + labelStyle = {}, + valueStyle = {}, + }) => { + const rootId = idBase || undefined; + const valueId = idBase ? `${idBase}-value` : undefined; + + return ( +
+ + {label} + +

+ {value} +

+
+ ); + }, +); + +LabeledValue.displayName = 'LabeledValue'; + +export default LabeledValue; diff --git a/playground/user-profile-app/src/components/UserList/EmptyResult.jsx b/playground/agentic-react-vite-playground/src/components/UserList/EmptyResult.jsx similarity index 100% rename from playground/user-profile-app/src/components/UserList/EmptyResult.jsx rename to playground/agentic-react-vite-playground/src/components/UserList/EmptyResult.jsx diff --git a/playground/agentic-react-vite-playground/src/components/UserList/UserList.jsx b/playground/agentic-react-vite-playground/src/components/UserList/UserList.jsx new file mode 100644 index 0000000..87a2c29 --- /dev/null +++ b/playground/agentic-react-vite-playground/src/components/UserList/UserList.jsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react'; +import { + Box, + Divider, + Grid, + List, + Stack, + Typography, +} from '@mui/material'; +import GroupsIcon from '@mui/icons-material/Groups'; +import PersonSearchIcon from '@mui/icons-material/PersonSearch'; +import TaskAltIcon from '@mui/icons-material/TaskAlt'; +import TuneIcon from '@mui/icons-material/Tune'; +import { useNavigate } from 'react-router-dom'; +import UserSearch from './UserSearch'; +import UserRow from './UserRow'; +import EmptyResult from './EmptyResult'; +import { mockUsers } from './mockData'; +import { + AppSurface, + DataPanel, + MetricCard, + PageHeader, + StatusChip, + ToolbarSurface, + VisualBriefing, +} from '../ui/MuiPlaygroundPrimitives'; + +const UserList = () => { + const [searchTerm, setSearchTerm] = useState(''); + const navigate = useNavigate(); + + const handleSearchChange = (event) => { + setSearchTerm(event.target.value); + }; + + const handleUserClick = (userId) => { + navigate(`/profile/${userId}`); + }; + + const filteredUsers = mockUsers.filter((user) => + user.firstName.toLowerCase().includes(searchTerm.toLowerCase()) || + user.lastName.toLowerCase().includes(searchTerm.toLowerCase()) || + user.occupation.toLowerCase().includes(searchTerm.toLowerCase()) || + user.location.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + + } + actions={}>Runtime ready} + /> + + + + Tier 1 contacts verified + Escalation owners assigned + Profile edits audited + + + + + + } label="People indexed" value={mockUsers.length} /> + + + } + label="Filtered results" + value={filteredUsers.length} + tone="secondary" + /> + + + } label="Selector surface" value="MUI wrapped" tone="warning" /> + + + + + + + + + + Directory command bar + + + Search by name, role, location, or operating domain; results route to editable profiles with validated contact fields. + + + + + + + + {searchTerm ? `Query: ${searchTerm}` : 'All users'}} + > + + {filteredUsers.length > 0 ? ( + filteredUsers.map((user, index) => ( + + handleUserClick(user.id)} + /> + {index < filteredUsers.length - 1 && + + } + + )) + ) : ( + + )} + + + + ); +}; + + +export default UserList; diff --git a/playground/agentic-react-vite-playground/src/components/UserList/UserRow.jsx b/playground/agentic-react-vite-playground/src/components/UserList/UserRow.jsx new file mode 100644 index 0000000..24c09a9 --- /dev/null +++ b/playground/agentic-react-vite-playground/src/components/UserList/UserRow.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { + Avatar, + Box, + Chip, + ListItem, + ListItemAvatar, + ListItemText, + Stack, +} from '@mui/material'; +import ApartmentIcon from '@mui/icons-material/Apartment'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import PlaceIcon from '@mui/icons-material/Place'; +import LabeledValue from '../Common/LabeledValue'; + +const UserRow = ({ user, onClick }) => { + return ( + } + sx={{ + px: 2.5, + py: 2, + transition: 'background-color 0.2s, transform 0.2s', + '&:hover': { + backgroundColor: 'rgba(15, 118, 110, 0.06)', + transform: 'translateX(2px)', + }, + }} + > + + + {user.firstName[0]}{user.lastName[0]} + + + + + {user.firstName} {user.lastName} + + + + } + secondary={ + + + Role} + value={user.occupation} + labelStyle={{ alignItems: 'center', display: 'flex', gap: '4px', fontSize: '0.72rem' }} + valueStyle={{ marginTop: '0', fontSize: '0.9rem', fontWeight: 700 }} + /> + Location} + value={user.location} + labelStyle={{ alignItems: 'center', display: 'flex', gap: '4px', fontSize: '0.72rem' }} + valueStyle={{ marginTop: '0', fontSize: '0.9rem', fontWeight: 700 }} + /> + + + } + /> + + ); +}; + +export default UserRow; diff --git a/playground/agentic-react-vite-playground/src/components/UserList/UserSearch.jsx b/playground/agentic-react-vite-playground/src/components/UserList/UserSearch.jsx new file mode 100644 index 0000000..349e44c --- /dev/null +++ b/playground/agentic-react-vite-playground/src/components/UserList/UserSearch.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { InputAdornment, TextField } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; + +const UserSearch = ({ searchTerm, handleSearchChange }) => { + return ( + + + + ), + }} + /> + ); +}; + + +export default UserSearch; diff --git a/playground/user-profile-app/src/components/UserList/index.jsx b/playground/agentic-react-vite-playground/src/components/UserList/index.jsx similarity index 100% rename from playground/user-profile-app/src/components/UserList/index.jsx rename to playground/agentic-react-vite-playground/src/components/UserList/index.jsx diff --git a/playground/agentic-react-vite-playground/src/components/UserList/mockData.js b/playground/agentic-react-vite-playground/src/components/UserList/mockData.js new file mode 100644 index 0000000..72d1000 --- /dev/null +++ b/playground/agentic-react-vite-playground/src/components/UserList/mockData.js @@ -0,0 +1,53 @@ +// Mock user data +export const mockUsers = [ + { + id: 1, + firstName: 'Avery', + lastName: 'Stone', + email: 'avery.stone@northstar.health', + phone: '(415) 610-1842', + bio: 'Owns the patient intake web surfaces for a multi-region healthcare platform, with a focus on accessible form design, service recovery dashboards, and measurable conversion improvements.', + location: 'San Francisco, CA', + occupation: 'Clinical Experience Lead', + }, + { + id: 2, + firstName: 'Priya', + lastName: 'Nair', + email: 'priya.nair@ledgerworks.io', + phone: '(212) 884-7301', + bio: 'Coordinates merchant onboarding, KYC exception review, and high-value account escalations for a regulated payments platform serving marketplace customers.', + location: 'New York, NY', + occupation: 'Payments Operations Manager', + }, + { + id: 3, + firstName: 'Mateo', + lastName: 'Johnson', + email: 'mateo.johnson@atlasgrid.energy', + phone: '(512) 449-2018', + bio: 'Maintains outage communication workflows, field dispatch views, and executive reliability summaries for distributed energy infrastructure teams.', + location: 'Austin, TX', + occupation: 'Grid Reliability Analyst', + }, + { + id: 4, + firstName: 'Elena', + lastName: 'Williams', + email: 'elena.williams@harborfreightlogistics.com', + phone: '(206) 730-9188', + bio: 'Leads shipment exception triage across ocean freight, customs holds, and warehouse appointment queues with daily SLA reporting to enterprise shippers.', + location: 'Seattle, WA', + occupation: 'Logistics Program Director', + }, + { + id: 5, + firstName: 'Noah', + lastName: 'Brown', + email: 'noah.brown@civiccloud.gov', + phone: '(312) 507-4410', + bio: 'Runs constituent support analytics, public records routing, and secure case escalation workflows for a municipal digital services organization.', + location: 'Chicago, IL', + occupation: 'Civic Services Architect', + }, +]; diff --git a/playground/user-profile-app/src/components/UserProfile/ProfileContent.jsx b/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileContent.jsx similarity index 89% rename from playground/user-profile-app/src/components/UserProfile/ProfileContent.jsx rename to playground/agentic-react-vite-playground/src/components/UserProfile/ProfileContent.jsx index 92ae0d8..2425358 100644 --- a/playground/user-profile-app/src/components/UserProfile/ProfileContent.jsx +++ b/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileContent.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Button, Grid, CardContent } from '@mui/material'; +import { Box, Button, CardContent, Grid, Stack } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -14,7 +14,6 @@ const ProfileContent = () => { } return ( - <> @@ -77,7 +76,7 @@ const ProfileContent = () => { {!editMode && ( - + - + )} - ); }; -export default ProfileContent; \ No newline at end of file +export default ProfileContent; diff --git a/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileField.jsx b/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileField.jsx new file mode 100644 index 0000000..fcf4a67 --- /dev/null +++ b/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileField.jsx @@ -0,0 +1,51 @@ +import React, { memo } from 'react'; +import { Paper, TextField } from '@mui/material'; +import { useProfile } from '../../context/ProfileContext'; +import LabeledValue from '../Common/LabeledValue'; + +const ProfileField = memo(({ + label, + name, + editMode, + multiline = false, + rows = 1 +}) => { + const { tempData, profileData, handleInputChange } = useProfile(); + const value = editMode ? tempData[name] : profileData[name]; + if (editMode) { + const commonProps = { + 'data-ext-id': `profile-field-${name}`, + id: `profile-field-${name}`, + name: name, + value: value, + onChange: handleInputChange, + }; + + return ( + + ); + } + + return ( + + + + ); +}); +ProfileField.displayName = 'ProfileField'; + +export default ProfileField; diff --git a/playground/user-profile-app/src/components/UserProfile/ProfileHeader.jsx b/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileHeader.jsx similarity index 50% rename from playground/user-profile-app/src/components/UserProfile/ProfileHeader.jsx rename to playground/agentic-react-vite-playground/src/components/UserProfile/ProfileHeader.jsx index 0988b69..c1079f0 100644 --- a/playground/user-profile-app/src/components/UserProfile/ProfileHeader.jsx +++ b/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileHeader.jsx @@ -1,8 +1,10 @@ import React from 'react'; -import { Box, Typography, Avatar, IconButton, Divider } from '@mui/material'; +import { Avatar, Box, Divider, IconButton, Stack, Typography } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; +import WorkIcon from '@mui/icons-material/Work'; +import LabeledValue from '../Common/LabeledValue'; const ProfileHeader = ({ profileData, @@ -13,43 +15,61 @@ const ProfileHeader = ({ }) => { return ( <> - - + + {profileData.firstName[0]}{profileData.lastName[0]} - + {profileData.firstName} {profileData.lastName} - - {profileData.occupation} - + Role} + value={profileData.occupation} + containerStyle={{ marginTop: '6px' }} + labelStyle={{ alignItems: 'center', display: 'flex', gap: '4px', fontSize: '0.72rem' }} + valueStyle={{ marginTop: '2px', fontSize: '0.95rem', fontWeight: 700 }} + /> - + {!editMode ? ( ) : ( - + @@ -57,10 +77,11 @@ const ProfileHeader = ({ color="error" onClick={onCancel} aria-label="cancel edit" + sx={{ border: '1px solid', borderColor: 'divider' }} > - + )} @@ -70,4 +91,4 @@ const ProfileHeader = ({ ProfileHeader.displayName = 'ProfileHeader'; -export default ProfileHeader; \ No newline at end of file +export default ProfileHeader; diff --git a/playground/user-profile-app/src/components/UserProfile/ProfileNotification.jsx b/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileNotification.jsx similarity index 72% rename from playground/user-profile-app/src/components/UserProfile/ProfileNotification.jsx rename to playground/agentic-react-vite-playground/src/components/UserProfile/ProfileNotification.jsx index fa107a7..9ef2b2d 100644 --- a/playground/user-profile-app/src/components/UserProfile/ProfileNotification.jsx +++ b/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileNotification.jsx @@ -1,14 +1,14 @@ import React from 'react'; import { Snackbar, Alert } from '@mui/material'; -const ProfileNotification = ({ - open, - onClose +const ProfileNotification = ({ + open, + onClose, }) => { return ( - @@ -21,4 +21,4 @@ const ProfileNotification = ({ ProfileNotification.displayName = 'ProfileNotification'; -export default ProfileNotification; \ No newline at end of file +export default ProfileNotification; diff --git a/playground/agentic-react-vite-playground/src/components/UserProfile/index.jsx b/playground/agentic-react-vite-playground/src/components/UserProfile/index.jsx new file mode 100644 index 0000000..53d8f7e --- /dev/null +++ b/playground/agentic-react-vite-playground/src/components/UserProfile/index.jsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react'; +import { Box, Button, Grid } from '@mui/material'; +import { useParams, useNavigate } from 'react-router-dom'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import BadgeIcon from '@mui/icons-material/Badge'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import MailIcon from '@mui/icons-material/Mail'; +import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; +import PlaceIcon from '@mui/icons-material/Place'; +import ProfileHeader from './ProfileHeader'; +import ProfileContent from './ProfileContent'; +import ProfileNotification from './ProfileNotification'; +import { useUserProfileData } from '../../hooks/useUserProfileData'; +import { ProfileProvider } from '../../context/ProfileContext'; +import { + AppSurface, + DataPanel, + MetricCard, + PageHeader, + StatusChip, + VisualBriefing, +} from '../ui/MuiPlaygroundPrimitives'; +const UserProfile = () => { + const { userId } = useParams(); + const navigate = useNavigate(); + const { + profileData, + editMode, + isLoading, + handleEditToggle, + handleSave: handleSaveFromHook, + handleCancel, + } = useUserProfileData(userId); + + const [openSnackbar, setOpenSnackbar] = useState(false); + + const handleSave = () => { + const success = handleSaveFromHook(); + if (success) { + setOpenSnackbar(true); + } + }; + + const handleCloseSnackbar = () => { + setOpenSnackbar(false); + }; + + const handleBackToList = () => { + navigate('/users'); + }; + + if (isLoading) { + return Loading...; + } + + if (!profileData) { + return null; + } + + return ( + + } + actions={ + + } + /> + + + + + + } label="Role" value={profileData.occupation} /> + + + } label="Location" value={profileData.location} tone="secondary" /> + + + } label="Contact" value="Verified" tone="success" /> + + + + }> + {editMode ? 'Editing' : 'Saved'} + + } + > + + + + + + + + + ); +}; + +// Specifically use a uniquely-suffixed displayName to distinguish from MUI components +UserProfile.displayName = 'UserProfile'; + +export default UserProfile; diff --git a/playground/user-profile-app/src/components/index.js b/playground/agentic-react-vite-playground/src/components/index.js similarity index 100% rename from playground/user-profile-app/src/components/index.js rename to playground/agentic-react-vite-playground/src/components/index.js diff --git a/playground/agentic-react-vite-playground/src/components/ui/MuiPlaygroundPrimitives.jsx b/playground/agentic-react-vite-playground/src/components/ui/MuiPlaygroundPrimitives.jsx new file mode 100644 index 0000000..0dcd4b0 --- /dev/null +++ b/playground/agentic-react-vite-playground/src/components/ui/MuiPlaygroundPrimitives.jsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { + Avatar, + Box, + Card, + CardContent, + Chip, + Container, + Divider, + Paper, + Stack, + Typography, +} from '@mui/material'; + +export function AppSurface({ children }) { + return ( + + {children} + + ); +} + +export function PageHeader({ eyebrow, title, description, icon, actions }) { + return ( + + + + + {icon} + + + + {eyebrow} + + + {title} + + + {description} + + + + {actions ? {actions} : null} + + + ); +} + +export function VisualBriefing({ imageSrc, title, body, children }) { + return ( + + + + {title} + + {body} + + {children ? {children} : null} + + + ); +} + +export function MetricCard({ icon, label, value, tone = 'primary' }) { + return ( + + + + + {icon} + + + + {label} + + {value} + + + + + ); +} + +export function ToolbarSurface({ children }) { + return ( + + {children} + + ); +} + +export function DataPanel({ title, subtitle, actions, children }) { + return ( + + + + {title} + {subtitle ? ( + + {subtitle} + + ) : null} + + {actions ? {actions} : null} + + + {children} + + ); +} + +export function StatusChip({ children, color = 'default', icon }) { + return ( + + ); +} diff --git a/playground/user-profile-app/src/context/ProfileContext.jsx b/playground/agentic-react-vite-playground/src/context/ProfileContext.jsx similarity index 100% rename from playground/user-profile-app/src/context/ProfileContext.jsx rename to playground/agentic-react-vite-playground/src/context/ProfileContext.jsx diff --git a/playground/user-profile-app/src/context/UserContext.jsx b/playground/agentic-react-vite-playground/src/context/UserContext.jsx similarity index 100% rename from playground/user-profile-app/src/context/UserContext.jsx rename to playground/agentic-react-vite-playground/src/context/UserContext.jsx diff --git a/playground/user-profile-app/src/hooks/useUserProfileData.js b/playground/agentic-react-vite-playground/src/hooks/useUserProfileData.js similarity index 100% rename from playground/user-profile-app/src/hooks/useUserProfileData.js rename to playground/agentic-react-vite-playground/src/hooks/useUserProfileData.js diff --git a/playground/user-profile-app/src/index.css b/playground/agentic-react-vite-playground/src/index.css similarity index 100% rename from playground/user-profile-app/src/index.css rename to playground/agentic-react-vite-playground/src/index.css diff --git a/playground/user-profile-app/src/index.jsx b/playground/agentic-react-vite-playground/src/index.jsx similarity index 100% rename from playground/user-profile-app/src/index.jsx rename to playground/agentic-react-vite-playground/src/index.jsx diff --git a/playground/user-profile-app/src/logo.svg b/playground/agentic-react-vite-playground/src/logo.svg similarity index 100% rename from playground/user-profile-app/src/logo.svg rename to playground/agentic-react-vite-playground/src/logo.svg diff --git a/playground/user-profile-app/src/setupTests.js b/playground/agentic-react-vite-playground/src/setupTests.js similarity index 100% rename from playground/user-profile-app/src/setupTests.js rename to playground/agentic-react-vite-playground/src/setupTests.js diff --git a/playground/agentic-react-vite-playground/tools/log1.ts b/playground/agentic-react-vite-playground/tools/log1.ts new file mode 100644 index 0000000..0c0f87e --- /dev/null +++ b/playground/agentic-react-vite-playground/tools/log1.ts @@ -0,0 +1,23 @@ +import type { ToolResultValue } from '@agentic-react/vite'; + +export default function myCustomTool(args: { + message: string; +}): ToolResultValue { + const { message } = args; + const runtime = typeof window === 'undefined' ? 'node' : 'browser'; + if (typeof window !== 'undefined') { + const targetWindow = window as typeof window & { + __AGENTIC_REACT_CUSTOM_TOOL_CALLS__?: string[]; + }; + targetWindow.__AGENTIC_REACT_CUSTOM_TOOL_CALLS__ = [ + ...(targetWindow.__AGENTIC_REACT_CUSTOM_TOOL_CALLS__ || []), + message, + ]; + } + console.info(`[custom-tool/log1] ${message}`); + return { + success: true, + message: `Log1 received: ${message}`, + runtime, + }; +} diff --git a/playground/agentic-react-vite-playground/vite.config.js b/playground/agentic-react-vite-playground/vite.config.js new file mode 100644 index 0000000..a3d8fb0 --- /dev/null +++ b/playground/agentic-react-vite-playground/vite.config.js @@ -0,0 +1,64 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import AgenticReact from '@agentic-react/vite'; +import { z } from 'zod'; +import log1 from './tools/log1.js'; + +export default defineConfig({ + plugins: [ + react(), + AgenticReact({ + customTools: [ + { + name: 'log1', + description: 'Log1', + schema: z.object({ + message: z.string(), + }), + clientFunction: log1, + }, + ], + toolkit: { + iconUrl: '/agentic-react-logo.png', + tuningModal: { + classNames: { + surface: 'vite-playground-tuning-surface', + panel: 'vite-playground-tuning-panel', + control: 'vite-playground-tuning-control', + }, + tokens: { + panelRadius: '14px', + controlRadius: '10px', + primaryButtonBackground: '#0f766e', + primaryButtonColor: '#ffffff', + panelShadow: '0 24px 72px rgba(15, 118, 110, 0.22)', + }, + styles: { + surface: { + filter: 'drop-shadow(0 18px 40px rgba(15, 118, 110, 0.16))', + }, + panel: { + border: '1px solid rgba(15, 118, 110, 0.22)', + }, + targetTag: { + background: '#ecfeff', + color: '#0f766e', + }, + sectionTitle: { + color: '#0f766e', + }, + }, + }, + }, + }), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 51423, + }, +}); diff --git a/playground/agentic-react-webpack-playground/.agentic-react-webpack/client-entry.mjs b/playground/agentic-react-webpack-playground/.agentic-react-webpack/client-entry.mjs new file mode 100644 index 0000000..d780609 --- /dev/null +++ b/playground/agentic-react-webpack-playground/.agentic-react-webpack/client-entry.mjs @@ -0,0 +1,47 @@ + +import { + __AGENTIC_REACT_BRIDGE_URL__, + __AGENTIC_REACT_CONFIG__, +} from "../../../packages/core/dist/shared/const.js"; +import { BRIDGE_WS_PATH } from "../../../packages/core/dist/shared/protocol.js"; + +if (typeof window !== 'undefined') { + const existingAgenticReactConfig = window[__AGENTIC_REACT_CONFIG__] || {}; + window[__AGENTIC_REACT_CONFIG__] = { + ...existingAgenticReactConfig, + sourceRoot: existingAgenticReactConfig.sourceRoot || "/Users/jazelly/Desktop/github/my-proj/vite-react-mcp/playground/agentic-react-webpack-playground", + toolkit: { + ...(existingAgenticReactConfig.toolkit || {}), + ...{"tuningModal":{"classNames":{"surface":"webpack-playground-tuning-surface","panel":"webpack-playground-tuning-panel","control":"webpack-playground-tuning-control"},"tokens":{"panelRadius":"12px","controlRadius":"9px","primaryButtonBackground":"#1d4ed8","primaryButtonColor":"#ffffff","panelShadow":"0 24px 72px rgba(29, 78, 216, 0.22)"},"styles":{"surface":{"filter":"drop-shadow(0 18px 40px rgba(29, 78, 216, 0.16))"},"panel":{"border":"1px solid rgba(29, 78, 216, 0.24)"},"targetTag":{"background":"#eff6ff","color":"#1d4ed8"},"sectionTitle":{"color":"#1d4ed8"}}}}, + }, + }; + + if (!window[__AGENTIC_REACT_BRIDGE_URL__]) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + window[__AGENTIC_REACT_BRIDGE_URL__] = + `${protocol}//${window.location.host}${BRIDGE_WS_PATH}`; + } +} + +void import("../../../packages/core/dist/overlay.js"); + + + const registerTool = (name, handler) => { + const tryRegister = (attempt = 0) => { + const registry = window.__AGENTIC_REACT_TOOLS__; + if (registry?.registerCustomTool) { + registry.registerCustomTool(name, handler); + return; + } + if (attempt >= 40) { + console.warn('[agentic-react] Custom tool registration timed out for', name); + return; + } + setTimeout(() => tryRegister(attempt + 1), 50); + }; + + tryRegister(); + }; + + + diff --git a/playground/agentic-react-webpack-playground/e2e/runtime.spec.js b/playground/agentic-react-webpack-playground/e2e/runtime.spec.js new file mode 100644 index 0000000..6fc4d94 --- /dev/null +++ b/playground/agentic-react-webpack-playground/e2e/runtime.spec.js @@ -0,0 +1,227 @@ +import { expect, test } from '@playwright/test'; + +const MCP_SERVER_URL = `http://127.0.0.1:${process.env.AGENTIC_REACT_WEBPACK_PLAYGROUND_PORT || '51425'}/mcp`; + +const createMcpClient = async () => { + const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([ + import('@modelcontextprotocol/sdk/client/index.js'), + import('@modelcontextprotocol/sdk/client/streamableHttp.js'), + ]); + const client = new Client({ + name: 'webpack-playground-mcp-e2e', + version: '0.0.0', + }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL)); + await client.connect(transport); + return { client, transport }; +}; + +const parseToolResponse = (result) => { + const textContent = result.content.find((content) => content.type === 'text'); + if (!textContent || typeof textContent.text !== 'string') { + return null; + } + return JSON.parse(textContent.text); +}; + +test('webpack playground injects runtime globals', async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const runtimeShape = await page.evaluate(() => ({ + hasRuntime: Boolean(window.__AGENTIC_REACT__), + hasTools: Boolean(window.__AGENTIC_REACT_TOOLS__), + hasSelectionMode: typeof window.__AGENTIC_REACT__?.setSelectionMode, + hasGetContext: typeof window.__AGENTIC_REACT__?.getLastSelectionContext, + hasCopyContext: typeof window.__AGENTIC_REACT__?.copyLastSelectionContext, + hasRegisterTuningModalExtension: + typeof window.__AGENTIC_REACT__?.registerTuningModalExtension, + tuningSurfaceClassName: + window.__AGENTIC_REACT_CONFIG__?.toolkit?.tuningModal?.classNames + ?.surface, + tuningPanelClassName: + window.__AGENTIC_REACT_CONFIG__?.toolkit?.tuningModal?.classNames?.panel, + unregisterType: (() => { + const unregister = + window.__AGENTIC_REACT__?.registerTuningModalExtension({ + id: 'webpack-runtime-smoke', + }); + unregister?.(); + return typeof unregister; + })(), + })); + + expect(runtimeShape.hasRuntime).toBe(true); + expect(runtimeShape.hasTools).toBe(true); + expect(runtimeShape.hasSelectionMode).toBe('function'); + expect(runtimeShape.hasGetContext).toBe('function'); + expect(runtimeShape.hasCopyContext).toBe('function'); + expect(runtimeShape.hasRegisterTuningModalExtension).toBe('function'); + expect(runtimeShape.tuningSurfaceClassName).toBe( + 'webpack-playground-tuning-surface', + ); + expect(runtimeShape.tuningPanelClassName).toBe( + 'webpack-playground-tuning-panel', + ); + expect(runtimeShape.unregisterType).toBe('function'); +}); + +test('webpack playground MCP tools return expected outcomes', async ({ + page, +}) => { + await page.goto('/'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + const { client, transport } = await createMcpClient(); + try { + const htmlMatchesRaw = await client.callTool({ + name: 'get-html-elements', + arguments: { + queries: ['html'], + maxMatches: 3, + }, + }); + const htmlMatches = parseToolResponse(htmlMatchesRaw); + expect(htmlMatches.success).toBe(true); + expect(htmlMatches.count).toBeGreaterThan(0); + expect(htmlMatches.matches[0].selector).toBeTruthy(); + + const reactSourceRaw = await client.callTool({ + name: 'get-react-source-code', + arguments: { + queries: ['html'], + maxMatches: 3, + includeSourceSnippets: true, + }, + }); + const reactSource = parseToolResponse(reactSourceRaw); + expect(reactSource.success).toBe(true); + expect(reactSource.chosenMatch.selector).toBeTruthy(); + expect(reactSource.context).toBeTruthy(); + + const noMatchRaw = await client.callTool({ + name: 'get-react-source-code', + arguments: { + queries: ['text-not-present-in-webpack-playground'], + }, + }); + const noMatch = parseToolResponse(noMatchRaw); + expect(noMatch.success).toBe(false); + expect(noMatch.reason).toContain('No element matched'); + expect(noMatch.context).toBeNull(); + + const enableSelectionRaw = await client.callTool({ + name: 'set-selection-mode', + arguments: { enabled: true }, + }); + const enableSelection = parseToolResponse(enableSelectionRaw); + expect(enableSelection.success).toBe(true); + expect(enableSelection.enabled).toBe(true); + + const contextRaw = await client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 3, + maxFiles: 3, + }, + }); + const context = parseToolResponse(contextRaw); + expect(context.success).toBe(false); + expect(context.context).toBeNull(); + + const copyRaw = await client.callTool({ + name: 'copy-last-selection-context', + arguments: { format: 'json' }, + }); + const copyResponse = parseToolResponse(copyRaw); + expect(copyResponse.success).toBe(false); + expect(copyResponse.copied).toBe(false); + expect(copyResponse.error).toContain('No selection context found'); + } finally { + await transport.close(); + } +}); + +test('webpack selection captures the primary CTA as userland AppContent source', async ({ + page, +}) => { + await page.goto('/'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + + await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + + const primaryCta = page.locator('.primary-cta'); + await expect(primaryCta).toBeVisible(); + const ctaBox = await primaryCta.boundingBox(); + expect(ctaBox).toBeTruthy(); + await page.mouse.move( + ctaBox.x + ctaBox.width / 2, + ctaBox.y + ctaBox.height / 2, + ); + + const hoverLabel = page.locator('[data-agentic-react-hover-label="true"]'); + await expect(hoverLabel).toHaveText(' in App.jsx:489'); + const hoverLabelBox = await hoverLabel.boundingBox(); + expect(hoverLabelBox).toBeTruthy(); + expect(hoverLabelBox.y + hoverLabelBox.height).toBeLessThanOrEqual( + ctaBox.y, + ); + + await primaryCta.click(); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '.hero > .hero-copy > .primary-cta', + ); + const selectedLabel = page.locator( + '[data-agentic-react-selected-label="true"]', + ); + await expect(selectedLabel).toHaveText(' in App.jsx:489'); + const selectedLabelBox = await selectedLabel.boundingBox(); + expect(selectedLabelBox).toBeTruthy(); + expect(selectedLabelBox.y + selectedLabelBox.height).toBeLessThanOrEqual( + ctaBox.y, + ); + + const runtimeContext = await page.evaluate(() => + window.__AGENTIC_REACT__.getLastSelectionContext(), + ); + expect(runtimeContext.componentName).toBe('AppContent'); + expect(runtimeContext.externalComponent).toBeNull(); + expect(runtimeContext.resolvedSources).toContainEqual( + expect.objectContaining({ + componentName: 'AppContent', + filePath: expect.stringContaining('src/App.jsx'), + lineNumber: 489, + }), + ); + + const { client, transport } = await createMcpClient(); + try { + const selectedContextRaw = await client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 3, + maxFiles: 2, + }, + }); + const selectedContext = parseToolResponse(selectedContextRaw); + expect(selectedContext.success).toBe(true); + expect(selectedContext.summary).toContain('component: AppContent'); + expect(selectedContext.summary).toContain('src/App.jsx:489'); + expect(selectedContext.summary).not.toContain( + 'selected external component:', + ); + expect(selectedContext.summary).not.toContain('component: a'); + expect(selectedContext.context.sourceSnippets).toContainEqual( + expect.objectContaining({ + filePath: 'src/App.jsx', + snippet: expect.stringContaining('className="primary-cta"'), + }), + ); + } finally { + await transport.close(); + } +}); diff --git a/playground/agentic-react-webpack-playground/hyperframes/multiselect-plugin-demo/AGENTS.md b/playground/agentic-react-webpack-playground/hyperframes/multiselect-plugin-demo/AGENTS.md new file mode 100644 index 0000000..6876b63 --- /dev/null +++ b/playground/agentic-react-webpack-playground/hyperframes/multiselect-plugin-demo/AGENTS.md @@ -0,0 +1,62 @@ +# HyperFrames Composition Project + +## Skills + +This project uses AI agent skills for framework-specific patterns. Install them if not already present: + +```bash +npx skills add heygen-com/hyperframes +``` + +Skills encode patterns like `window.__timelines` registration, `data-*` attribute semantics, Tailwind v4 browser-runtime styling for `--tailwind` projects, and shader-compatible CSS rules that are not in generic web docs. Using them produces correct compositions from the start. + +## Commands + +```bash +npm run dev # start the preview server (long-running — keep it alive in background) +npm run check # lint + validate + inspect +npm run render # render to MP4 +npm run publish # publish and get a shareable link +npx hyperframes docs # reference docs in terminal +``` + +> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. +> Always run it as a background process so it stays alive while you edit compositions. +> Running it in the foreground will time out and kill the server, breaking the browser preview. + +## Project Structure + +- `index.html` — main composition (root timeline) +- `compositions/` — sub-compositions referenced via `data-composition-src` +- `assets/` — media files (video, audio, images) +- `meta.json` — project metadata (id, name) +- `transcript.json` — whisper word-level transcript (if generated) + +## Linting — Always Run After Changes + +After creating or editing any `.html` composition, run the full check before considering the task complete: + +```bash +npm run check +``` + +Fix all errors before presenting the result. + +## Key Rules + +1. Every timed element needs `data-start`, `data-duration`, and `data-track-index` +2. Visible timed elements **must** have `class="clip"` — the framework uses this for visibility control +3. GSAP timelines must be paused and registered on `window.__timelines`: + ```js + window.__timelines = window.__timelines || {}; + window.__timelines["composition-id"] = gsap.timeline({ paused: true }); + ``` +4. Videos use `muted` with a separate `