diff --git a/.changeset/spotty-bottles-approve.md b/.changeset/spotty-bottles-approve.md new file mode 100644 index 0000000..5952b3c --- /dev/null +++ b/.changeset/spotty-bottles-approve.md @@ -0,0 +1,8 @@ +--- +"@agentic-react/core": minor +"@agentic-react/vite": minor +"@agentic-react/webpack": minor +"@agentic-react/next": minor +--- + +Add persistent toolbox settings for shortcuts and launcher icon customization, plus explicit Done/Enter selection confirmation. diff --git a/.changeset/tidy-tools-resolve.md b/.changeset/tidy-tools-resolve.md new file mode 100644 index 0000000..6bda22e --- /dev/null +++ b/.changeset/tidy-tools-resolve.md @@ -0,0 +1,8 @@ +--- +"@agentic-react/core": patch +"@agentic-react/next": patch +"@agentic-react/vite": patch +"@agentic-react/webpack": patch +--- + +Resolve selected component source locations over the runtime bridge so Next.js applications no longer receive missing source-lookup route requests. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd8cd61..310b4a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: pull_request: - branches: [ main, master ] + branches: [dev, main, master] push: - branches: [ main, master ] + branches: [dev, main, master] jobs: build: @@ -36,11 +36,14 @@ jobs: run: pnpm --filter agentic-react-vite-playground exec playwright install --with-deps chromium - name: Lint - run: pnpm run lint + run: pnpm exec biome lint . - name: Release smoke test run: pnpm run test:release-smoke + - name: Unit tests + run: pnpm run test:unit + - name: Build run: pnpm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56ad0aa..cfe1f2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,20 +5,90 @@ on: branches: [main] concurrency: - group: release-${{ github.ref }} + group: release-main cancel-in-progress: false -permissions: - contents: write - id-token: write - jobs: + preflight: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + release-required: ${{ steps.release-state.outputs.release-required }} + release-mode: ${{ steps.release-state.outputs.release-mode }} + release-sha: ${{ steps.release-state.outputs.release-sha }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Inspect release state + id: release-state + run: | + git fetch origin main:refs/remotes/origin/main + release_sha="$(git rev-parse origin/main)" + git checkout --detach "$release_sha" + + if [ -f .changeset/release-pending.json ]; then + marker_commit="$(git log -1 --format=%H -- .changeset/release-pending.json)" + marker_parent="$(git rev-parse "$marker_commit^")" + marker_source="$(sed -n 's/.*"sourceSha": "\([0-9a-f]*\)".*/\1/p' .changeset/release-pending.json)" + marker_subject="$(git show -s --format=%s "$marker_commit")" + marker_author="$(git show -s --format='%an <%ae>' "$marker_commit")" + + if [ "$marker_parent" != "$marker_source" ] || \ + [ "$marker_subject" != "chore(release): version packages" ] || \ + [ "$marker_author" != "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" ] || \ + ! git ls-tree -r --name-only "$marker_parent" .changeset | \ + grep -E '^\.changeset/[^/]+\.md$' | \ + grep -v '^\.changeset/README\.md$' | grep -q .; then + echo "::error::The release recovery marker was not created by the release workflow." + exit 1 + fi + + release_sha="$marker_commit" + release_required=true + release_mode=recovery + reason="A previous package publication is pending." + elif find .changeset -maxdepth 1 -name "*.md" ! -name "README.md" | grep -q .; then + release_required=true + release_mode=version + reason="Pending changesets require a package release." + else + release_required=false + release_mode=none + reason="No pending changesets or interrupted release were found." + fi + + { + echo "release-required=$release_required" + echo "release-mode=$release_mode" + echo "release-sha=$release_sha" + } >> "$GITHUB_OUTPUT" + + { + echo "### Release preflight" + echo + echo "- Required: $release_required" + echo "- Mode: $release_mode" + echo "- Main SHA: $release_sha" + echo "- Reason: $reason" + } >> "$GITHUB_STEP_SUMMARY" + release: + needs: preflight + if: github.ref == 'refs/heads/main' && needs.preflight.outputs.release-required == 'true' runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + id-token: write steps: - name: Checkout uses: actions/checkout@v4 with: + ref: ${{ needs.preflight.outputs.release-sha }} fetch-depth: 0 - name: Setup PNPM @@ -34,31 +104,111 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Configure Git + - name: Version packages from changesets + if: needs.preflight.outputs.release-mode == 'version' run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + pnpm changeset status --output .changeset/release-plan.json + pnpm run version-packages + printf '{\n "sourceSha": "%s"\n}\n' \ + "${{ needs.preflight.outputs.release-sha }}" \ + > .changeset/release-pending.json + git add -A - - name: Version packages from changesets + - name: Validate recovery state + if: needs.preflight.outputs.release-mode == 'recovery' run: | - if find .changeset -maxdepth 1 -name "*.md" ! -name "README.md" | grep -q .; then - pnpm run version-packages + test -f .changeset/release-pending.json + test -f .changeset/release-plan.json + + verification_dir="$(mktemp -d)" + trap 'git worktree remove --force "$verification_dir"' EXIT + git worktree add --detach \ + "$verification_dir" \ + "${{ needs.preflight.outputs.release-sha }}^" + + ( + cd "$verification_dir" + node "$GITHUB_WORKSPACE/node_modules/@changesets/cli/bin.js" \ + status --output .changeset/release-plan.json + node "$GITHUB_WORKSPACE/node_modules/@changesets/cli/bin.js" version + printf '{\n "sourceSha": "%s"\n}\n' \ + "$(git rev-parse HEAD)" \ + > .changeset/release-pending.json git add -A - git commit -m "chore: version packages" - git push origin HEAD:main - else - echo "No pending changesets to version." - fi + + expected_tree="$(git write-tree)" + release_tree="$(git rev-parse '${{ needs.preflight.outputs.release-sha }}^{tree}')" + test "$expected_tree" = "$release_tree" + ) + + - name: Install Playwright Chromium + run: pnpm --filter agentic-react-vite-playground exec playwright install --with-deps chromium + + - name: Lint + run: pnpm exec biome lint . + + - name: Release smoke test + run: pnpm run test:release-smoke + + - name: Unit tests + run: pnpm run test:unit - name: Build packages run: pnpm run build + - name: Test + run: pnpm run test:e2e -- --reporter=line --workers=1 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Commit version packages + if: needs.preflight.outputs.release-mode == 'version' + run: | + git commit -m "chore(release): version packages" + git push origin HEAD:main + - name: Publish packages + id: publish + continue-on-error: true run: pnpm run release env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Ensure package publication succeeded + if: steps.publish.outcome != 'success' + run: | + echo "Package publication failed; recovery state has been retained." + exit 1 + + - name: Reconcile release tags + run: | + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + import { readFileSync } from 'node:fs'; + + const plan = JSON.parse( + readFileSync('.changeset/release-plan.json', 'utf8'), + ); + + for (const release of plan.releases) { + const tag = `${release.name}@${release.newVersion}`; + + try { + execFileSync('git', ['rev-parse', '--verify', `refs/tags/${tag}`], { + stdio: 'ignore', + }); + console.info(`Existing tag: ${tag}`); + } catch { + execFileSync('git', ['tag', tag]); + console.info(`New tag: ${tag}`); + } + } + NODE + - name: Push release tags run: git push origin --tags @@ -80,3 +230,17 @@ jobs: gh release create "$tag" --title "$tag" --generate-notes --latest fi done + + - name: Clear recovery state + run: | + git fetch origin main:refs/remotes/origin/main + git checkout --detach origin/main + + if [ ! -f .changeset/release-pending.json ]; then + echo "Recovery state was already cleared." + exit 0 + fi + + git rm .changeset/release-pending.json .changeset/release-plan.json + git commit -m "chore(release): complete package publication" + git push origin HEAD:main diff --git a/README.md b/README.md index f5de310..d82fc58 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,23 @@ # Agentic React -Agentic React is a monorepo for React runtime inspection and local-dev MCP integrations. The packages are published under the `@agentic-react` namespace. +[English](./README.md) | [中文](./README.zh-CN.md) + +**Give coding agents the exact React context behind the UI they are editing.** + +Agentic React annotates selected React UI with component names, DOM selectors, source file locations, source snippets, owner traces, and local MCP tools, so an agent can move from "this thing on the page" to the right code without guessing. + +```text + +component: ProfileField +selector: #profile-field-email +source: src/components/UserProfile/ProfileField.jsx:19 +source trace: + -> at src/components/UserProfile/ProfileField.jsx:19 + -> at src/components/UserProfile/ProfileContent.jsx:54 + +``` + +Install one dev adapter, select one or many elements in the browser, and pass source-aware React context directly to your agent. ## Demos @@ -12,6 +29,16 @@ Agentic React is a monorepo for React runtime inspection and local-dev MCP integ ![Multiselect demo](./playground/demo/demo2-multiselect.gif) +## Why Agentic React + +- **Source-aware selection:** click real UI and capture the React component, stable selector, source location, and nearby source code. +- **Agent-ready context:** copy the selection as text or JSON, or expose it through MCP for local coding agents. +- **Bundler-native adapters:** use the same runtime with Vite, Webpack, Next.js, and Nx/module-federation playgrounds. +- **Dev-only bridge:** keep local source lookup and MCP transport in development tooling, outside production bundles. +- **Multi-select and tuning:** collect several UI targets, inspect styling, and turn visual adjustments into prompt-ready instructions. + +The packages are published under the `@agentic-react` namespace. + ## Packages - `@agentic-react/core`: bundler-agnostic browser runtime, React selection toolkit, and MCP primitives. @@ -135,8 +162,82 @@ Bundler adapters add local-dev capabilities that the runtime cannot know on its - 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 +- persist toolbox Settings through the adapter-hosted bridge - keep dev-only tooling out of production bundles +## Settings and Selection Confirmation + +The toolbox includes a Settings section when it runs through a supported dev adapter. Settings can configure the single-select, multiselect, toolbox-toggle, and Done shortcuts, and can replace the launcher icon with a cropped custom image. + +User settings are global to the local machine and are stored under `~/.agentic-react/`. They are not scoped to a project, browser origin, hostname, port, or bundler. Effective values are resolved in this order: + +```text +global user override + > project configuration default + > package default +``` + +Resetting a setting removes only the global override, revealing the project default when one exists and otherwise the package default. Project defaults come from adapter options: `toolkit.settings.shortcuts` for shortcut defaults and `toolkit.iconUrl` for the default toolbox icon. + +The package defaults are: + +| Action | Default shortcut | +| --- | --- | +| Single select | `Ctrl+Alt+Shift+S` | +| Multi select | `Ctrl+Alt+Shift+M` | +| Toggle toolbox | `Ctrl+Alt+Shift+A` | +| Done | `Enter` | + +`Escape` is reserved for cancellation and is not configurable. Shortcut recording validates one non-modifier key, supported keys only, and no duplicate normalized shortcuts. + +`settingsRoot` is an adapter option for isolated environments such as tests, demos, and temporary sandboxes. Production-like local usage should normally omit it so Agentic React uses the user's global `~/.agentic-react` directory. + +```ts +// Vite +AgenticReact({ + settingsRoot: '/tmp/agentic-react-settings', + toolkit: { + iconUrl: '/agentic-react-logo.png', + settings: { + shortcuts: { + singleSelect: 'Ctrl+Shift+S', + done: 'Enter', + }, + }, + }, +}); + +// Webpack +withAgenticReactWebpack(config, { mode: 'development' }, { + settingsRoot: '/tmp/agentic-react-settings', + toolkit: { + iconUrl: '/agentic-react-logo.png', + settings: { + shortcuts: { + multiSelect: 'Ctrl+Shift+M', + }, + }, + }, +}); + +// Next.js +withAgenticReactNext(nextConfig, { + settingsRoot: '/tmp/agentic-react-settings', + toolkit: { + iconUrl: '/agentic-react-logo.png', + settings: { + shortcuts: { + toggleToolbox: 'Ctrl+Shift+A', + }, + }, + }, +}); +``` + +Selection is an explicit transaction. Clicking a target captures and displays context as pending; it does not copy immediately. Click Done or press the configured Done shortcut, `Enter` by default, to copy and commit the pending selection. In multiselect mode, Done copies the complete pending set. `Escape` cancels pending single and multiselect work without copying and preserves the last committed selection. + +Runtime-only `@agentic-react/core` usage can still run browser-side selection APIs and code-provided toolkit defaults, but it has no persistent Settings without an adapter-hosted bridge. + ## Tuning Modal API The selection overlay includes a tuning modal for turning visual adjustments into prompt text. Configure it through the adapter `toolkit` option, or at runtime with `window.__AGENTIC_REACT__.setToolkitConfig()`. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..d092dca --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,285 @@ +# Agentic React + +[English](./README.md) | [中文](./README.zh-CN.md) + +**把 UI 背后的 React 上下文和源码位置,直接交给 coding agent。** + +Agentic React 会为你选中的 React UI 标注 component name、DOM selector、source file location、source snippet、owner trace,并通过本地 MCP tools 暴露给 agent。这样 agent 不需要靠猜测从“页面上这个东西”定位到代码,而是可以直接拿到可执行的 React/source context。 + +```text + +component: ProfileField +selector: #profile-field-email +source: src/components/UserProfile/ProfileField.jsx:19 +source trace: + -> at src/components/UserProfile/ProfileField.jsx:19 + -> at src/components/UserProfile/ProfileContent.jsx:54 + +``` + +安装一个 dev adapter,在浏览器里选择一个或多个元素,然后把 source-aware React context 直接传给你的 agent。 + +## Demo + +### Single Select + +![Single Select demo](./playground/demo/demo1-single-select.gif) + +### Multiselect + +![Multiselect demo](./playground/demo/demo2-multiselect.gif) + +## 为什么用 Agentic React + +- **Source-aware selection:** 点击真实 UI,捕获 React component、稳定 selector、源码位置和附近源码。 +- **Agent-ready context:** selection 可以复制成 text/JSON,也可以通过 MCP 暴露给本地 coding agent。 +- **Bundler-native adapters:** 同一套 runtime 支持 Vite、Webpack、Next.js,以及 Nx/module-federation playground。 +- **Dev-only bridge:** 本地源码查找和 MCP transport 只存在于开发工具链,不进入 production bundle。 +- **Multi-select and tuning:** 一次收集多个 UI target,检查样式,并把视觉调整转成 prompt-ready instructions。 + +packages 发布在 `@agentic-react` namespace 下。 + +## Packages + +- `@agentic-react/core`: bundler-agnostic browser runtime、React selection toolkit 和 MCP primitives。 +- `@agentic-react/vite`: Vite 本地开发 adapter。 +- `@agentic-react/webpack`: Webpack 本地开发 adapter。 +- `@agentic-react/next`: Next.js 本地开发 adapter。 + +完整的 local-dev 功能需要安装对应 bundler 的 adapter。adapter 内部依赖 `@agentic-react/core`,所以 app 用户通常不需要同时安装二者。 + +## Local Dev Usage + +### Vite + +```bash +pnpm install @agentic-react/vite -D +``` + +```ts +// vite.config.ts +import { defineConfig } from 'vite'; +import AgenticReact from '@agentic-react/vite'; + +export default defineConfig({ + plugins: [AgenticReact()], +}); +``` + +Vite adapter 会注入 core browser runtime,把 runtime bridge 挂到 dev server,并通过下面的地址暴露 MCP: + +```text +http://localhost:/mcp +``` + +### Webpack + +```bash +pnpm install @agentic-react/webpack -D +``` + +```js +// webpack.config.mjs +import withAgenticReactWebpack from '@agentic-react/webpack'; + +export default (env, argv) => + withAgenticReactWebpack(config, { mode: argv.mode }); +``` + +Webpack adapter 会 prepend generated runtime entry,把 runtime bridge 挂到 webpack-dev-server,并在这里暴露 MCP: + +```text +http://localhost:/mcp +``` + +### Next.js + +```bash +pnpm install @agentic-react/next -D +``` + +```js +// next.config.mjs +import withAgenticReactNext from '@agentic-react/next'; + +export default withAgenticReactNext(nextConfig); +``` + +Next adapter 会通过 Next 的 Webpack config 注入 browser runtime,并启动本地 bridge server。默认 MCP 地址: + +```text +http://127.0.0.1:51426/mcp +``` + +## Runtime-Only Usage + +如果只需要 browser-side selection/runtime APIs,不需要本地源码查找或 MCP dev-server wiring,可以直接使用 `@agentic-react/core`。 + +```bash +pnpm install @agentic-react/core +``` + +```ts +import { createSelectionToolkit } from '@agentic-react/core'; + +const toolkit = createSelectionToolkit(); +toolkit.enable(); +``` + +runtime-only mode 可以检查 live browser tree,但不能读取或编辑本地文件。source lookup 和 local MCP transport 是 adapter layer 的职责。 + +## Adapters 额外提供什么 + +`@agentic-react/core` 运行在浏览器中,可以选择元素、检查 React fibers、高亮组件、格式化 selection context,并暴露 `window.__AGENTIC_REACT__` / `window.__AGENTIC_REACT_TOOLS__`。 + +Bundler adapters 会增加 runtime 自己无法知道的 local-dev 能力: + +- 自动注入 core runtime +- 挂载本地 MCP Streamable HTTP `/mcp` endpoint +- 把 MCP calls 从 Node bridge 到 browser runtime +- 提供 source-root context,用于 source lookup +- 让 dev-only tooling 不进入 production bundles + +## Tuning Modal API + +selection overlay 包含 tuning modal,可以把视觉调整转换成 prompt text。你可以通过 adapter 的 `toolkit` option 配置它,也可以在 runtime 中调用 `window.__AGENTIC_REACT__.setToolkitConfig()`。 + +```ts +import type { ToolkitConfig } from '@agentic-react/vite'; + +// Import ToolkitConfig from the adapter package you use. +const toolkit: ToolkitConfig = { + enabled: true, + defaultVisible: true, + defaultExpanded: false, + position: 'bottom-right', + offset: { x: 20, y: 20 }, + accentColor: '#111827', + zIndex: 2147483000, + iconUrl: '/agentic-react-logo.png', + tuningModal: { + classNames: { + surface: 'my-tuning-surface', + panel: 'my-tuning-panel', + control: 'my-tuning-control', + }, + styles: { + panel: { border: '1px solid rgba(15, 23, 42, 0.18)' }, + targetTag: { background: '#f8fafc', color: '#0f172a' }, + }, + tokens: { + panelRadius: '14px', + controlRadius: '10px', + primaryButtonBackground: '#0f766e', + primaryButtonColor: '#ffffff', + panelShadow: '0 24px 72px rgba(15, 118, 110, 0.22)', + }, + }, +}; +``` + +`tuningModal.classNames` 会把 classes 加到 modal slots 上。`tuningModal.styles` 会把 inline style objects 应用到同样的 slots。支持的 slots 包括 `root`, `surface`, `panel`, `arrow`, `title`, `body`, `targetTag`, `customPromptForm`, `customPromptInput`, `customPromptButton`, `sectionTitle`, `row`, `label`, `controlWrap`, `control`, `colorInput`, `numberInput`, `stepperButton`, `select`, `textarea`, `suffix`, 和 `closeButton`。 + +`tuningModal.tokens` 会把 camelCase token names 映射到以 `--agentic-react-tuning-` 开头的 CSS variables。比如 `panelRadius` 会变成 `--agentic-react-tuning-panel-radius`,`primaryButtonBackground` 会变成 `--agentic-react-tuning-primary-button-background`。 + +adapter 这样传入 config: + +```ts +// Vite +AgenticReact({ toolkit }); + +// Webpack +withAgenticReactWebpack(config, { mode: 'development' }, { toolkit }); + +// Next.js +withAgenticReactNext(nextConfig, { toolkit }); +``` + +如果需要结构级扩展,可以注册 browser-side tuning modal extension。slot renderers 可以在 fields 前后或 footer 中加入自定义 DOM。`wrapModal` 会收到 `surfaceElement` 和 `panelElement`,方便 design system 加 wrappers、data attributes、observers、portals 或 cleanup-aware 行为。 + +```ts +const unregister = window.__AGENTIC_REACT__?.registerTuningModalExtension({ + id: 'design-system-audit', + beforeFields({ container, context }) { + const badge = document.createElement('div'); + badge.textContent = `Editing ${context.tagName}`; + badge.className = 'agentic-design-system-badge'; + container.appendChild(badge); + }, + wrapModal({ surfaceElement, actions }) { + surfaceElement.dataset.designSystem = 'acme'; + const onTransitionEnd = () => actions.requestReposition(); + surfaceElement.addEventListener('transitionend', onTransitionEnd); + return () => { + surfaceElement.removeEventListener('transitionend', onTransitionEnd); + }; + }, +}); +``` + +## Custom Tools + +Adapters 支持 browser-side custom tools。shared types 可以从你正在使用的 adapter package 中 import: + +```ts +import type { ToolResultValue } from '@agentic-react/vite'; + +export default function logMessage(args: { message: string }): ToolResultValue { + return { + success: true, + message: `Received: ${args.message}`, + }; +} +``` + +```ts +import { defineConfig } from 'vite'; +import AgenticReact from '@agentic-react/vite'; +import { z } from 'zod'; +import logMessage from './src/tools/logMessage'; + +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, + }, + ], + }), + ], +}); +``` + +## Release Smoke Test + +本地运行 release automation smoke test: + +```bash +pnpm run test:release-smoke +``` + +这个测试不会 publish 到 npm,也不会调用 GitHub。它会读取 `.github/workflows/release.yml`,创建临时 git fixtures,并使用 fake `pnpm` 和 `gh` commands 覆盖 release automation branches。 + +## Development + +```bash +pnpm run build +pnpm run playground:vite +pnpm run playground:webpack +pnpm run playground:next +pnpm run playground:nx-mf +``` + +e2e automation 使用 playground package configs 中固定的本地端口。 + +## Acknowledgement + +This project is inspired by [vite-plugin-vue-mcp](https://github.com/webfansplz/vite-plugin-vue-mcp). + +## License + +MIT diff --git a/docs/plans/toolbox-icon-customization.md b/docs/plans/toolbox-icon-customization.md new file mode 100644 index 0000000..6eec656 --- /dev/null +++ b/docs/plans/toolbox-icon-customization.md @@ -0,0 +1,325 @@ +# Toolbox Settings and Selection Confirmation Plan + +## Status + +Accepted implementation plan. The global settings engine and browser integration described here are the source of truth for this feature. + +## Goal + +Give the Agentic React toolbox one coherent Settings surface for: + +- single-select, multiselect, toolbox-toggle, and Done shortcuts; +- a globally customized launcher icon; +- clear source labels and per-setting reset actions. + +At the same time, make selection an explicit transaction: + +- clicking a target captures context but does not copy it; +- Done copies and commits the pending selection; +- Escape cancels the pending selection without copying; +- interactions with Agentic React UI do not activate or dismiss host-app UI. + +All implementation and defaults live in this repository. A consuming project only supplies optional project defaults through its Agentic React configuration. + +## Product Decisions + +### Global ownership and precedence + +User settings are global to the local machine. They are not scoped to a repository, browser origin, hostname, port, or bundler. + +The precedence for every configurable value is: + +```text +global user override + > project configuration default + > package default +``` + +Reset removes only the global override. It reveals the project default when one exists, otherwise the package default. + +The Settings UI identifies the effective source as `Global override`, `Project configuration`, or `Default`. + +### Persistence + +Persist settings under the user's home directory: + +```text +~/.agentic-react/settings.json +~/.agentic-react/toolbox-icon.webp +``` + +Use `toolbox-icon.png` only when PNG is required as the encoding fallback. + +There is deliberately no project hash and no project-local settings file. Moving or switching projects does not change the user's shortcuts or icon. The consuming project never owns the persisted user preference. + +Tests and playgrounds must inject an isolated settings root and must never read or write the developer's real `~/.agentic-react` directory. + +### Why browser code can use home-directory settings + +Browser code cannot read the local filesystem directly. Persistence is therefore a runtime operation, not a bundler-time file import: + +1. A supported adapter starts Agentic React's existing local development bridge. +2. The Node side reads `~/.agentic-react` and merges global overrides with project and package defaults. +3. The adapter injects a sanitized initial snapshot and an unguessable session capability into the development client. +4. The browser reads or updates settings through narrow request/response operations on that existing bridge. +5. Node validates and atomically persists mutations, then returns a new effective snapshot. + +This extends the adapter-hosted Agentic React bridge. It does not replace Vite HMR, Webpack's dev transport, or Next's development transport, and it does not introduce a second public bridge API. Vite hosts the Agentic React WebSocket path on its existing HTTP server; that path is separate from Vite's HMR protocol. + +No filesystem path is sent to the browser. Persisted icons are returned as bounded, validated data URLs. + +### No browser-storage fallback or file watcher + +Do not use `localStorage` or IndexedDB as a fallback. Runtime-only `@agentic-react/core` usage may use code defaults, but Settings persistence is unavailable without an adapter-hosted bridge. + +The first version does not watch `settings.json` for external edits. Settings change through the toolbox UI and take effect from the RPC response. A page reload reads the latest persisted state. + +## Selection Semantics + +### Single select + +Single select uses a pending transaction: + +1. Enter selection mode from the button or configured shortcut. +2. Click a host element to capture and display its context. +3. Keep the selection pending and show an enabled Done action. +4. Do not write to the clipboard yet. +5. Clicking Done or pressing the configured Done shortcut copies the context and commits it as the last confirmed selection. + +Explicit programmatic copy APIs remain available, but they copy only the last committed selection. They must not expose or copy an unconfirmed pending single selection. + +If clipboard writing fails, keep the pending selection so the user can retry. + +### Multiselect + +Multiselect remains additive. Captured contexts and overlays stay pending until Done. Done copies the complete pending set. Clear All removes only the pending multiselect set. + +### Escape + +Escape is reserved and is not configurable. + +While Agentic React owns an active or pending selection, it captures Escape during the capture phase, prevents the default action, and stops propagation. It must consume the complete physical key cycle, including repeated keydown events and the matching keyup. + +Escape: + +- exits single-select or multiselect mode; +- discards pending single and multiselect contexts; +- removes pending hover, dim, selection, and tuning overlays; +- does not copy anything; +- preserves the last committed selection; +- keeps the toolbox open and displays `Selection cancelled`. + +## Host Event Isolation + +Every interactive Agentic React surface must form an activation-event boundary, including: + +- launcher and toolbox panel; +- Settings controls; +- selected-element actions; +- multiselect actions; +- tuning UI; +- icon crop modal and backdrop. + +At a minimum, stop propagation for: + +```text +pointerdown +pointerup +mousedown +mouseup +click +touchstart +touchend +contextmenu +``` + +Internal handlers still run normally. Isolation prevents common host outside-click and activation handlers from closing transient menus, popovers, dialogs, or other short-lived UI while the user operates the toolbox. + +## Settings UI + +Add a Settings section inside the existing toolbox panel. It contains `Shortcuts` and `Appearance` subsections. + +When persistence is unavailable, show effective code/package defaults and a concise capability explanation. Do not show controls that pretend a change can be saved. + +### Shortcuts + +The configurable actions are: + +| Action | Package default | +| --- | --- | +| Single select | `Ctrl+Alt+Shift+S` | +| Multi select | `Ctrl+Alt+Shift+M` | +| Toggle toolbox | `Ctrl+Alt+Shift+A` | +| Done | `Enter` | + +Each row shows the action, effective shortcut, source badge, record action, and reset action. + +Recording captures one normalized combination. Validation occurs both in the browser and in the Node settings engine. Reject: + +- Escape; +- a modifier without a non-modifier key; +- unsupported keys; +- more than one non-modifier key; +- a shortcut that duplicates another configured action after normalization. + +The Node write path validates the merged effective shortcut set so direct runtime/RPC callers cannot bypass UI validation. + +### Appearance + +The Toolbox icon row contains: + +- a circular preview; +- an effective-source badge; +- Change; +- Reset when a global override exists; +- an inline capability or validation error when applicable. + +The project default remains the existing `toolkit.iconUrl`. The first version does not require a new URL/file source union: the project supplies a browser-safe default URL, and user customization is stored globally through Settings. + +## Image Customization + +### Crop workflow + +Every accepted upload opens a 1:1 crop modal with: + +- a circular launcher preview; +- drag-to-position; +- zoom; +- left and right 90-degree rotation; +- a centered default crop; +- Apply and Cancel. + +Preview changes are transient. The launcher and persisted data change only after Apply has been encoded, validated, and persisted successfully. Cancel has no side effects. + +The result is a static square `256 x 256` image. Prefer WebP and fall back to PNG. Preserve transparency where supported and strip metadata through canvas re-encoding. Animated inputs become a single static frame. SVG is rejected. + +### Validation limits + +Validate decoded content rather than trusting the extension or reported MIME type: + +- maximum source size: `20 MB`; +- maximum side: `8192 px`; +- maximum decoded area: `40 megapixels`; +- browser-decodable raster input only; +- SVG and malformed input rejected; +- persisted encoded result capped at `1 MB`; +- Node verifies declared MIME, fixed filename, size, and magic bytes. + +Never send the original upload to Node. Send only the encoded 256-square result. + +### Failure behavior + +- Preload a candidate before replacing the launcher image. +- Keep the previous visible icon until Apply succeeds. +- Roll back image bytes if settings metadata cannot be committed. +- On missing or corrupt persisted icon data, return a structured error and render the next valid default without a broken-image indicator. +- Write settings through a temporary sibling and atomic rename. +- Reset commits metadata before deleting the old icon file. + +### Accessibility + +- All controls have accessible names and are keyboard operable. +- The crop modal traps focus and restores it to Change when closed. +- Errors use a live status region. +- Escape closes the crop modal when selection cancellation does not have priority. +- Circular masking is preview-only; the persisted file remains square. + +## Architecture + +### Browser modules + +Keep cohesive browser logic under `packages/core/src/core/settings/`: + +- `browser.ts`: typed settings RPC client and cached snapshot; +- `shortcuts.ts`: normalization, identity comparison, and dispatch; +- `ui.ts`: small Settings UI and event-boundary helpers; +- `image_cropper.ts`: decode validation, crop math, rendering, and encoding; +- `icon_modal.ts`: modal lifecycle, focus management, and cropper controls. + +`selection_toolkit.ts` coordinates selection state and consumes these modules. It owns no filesystem behavior. + +### Node settings engine + +The shared Node implementation under `packages/core/src/core/settings/` owns: + +- home-root resolution with a test-only root override; +- schema parsing and forward-safe fallback; +- global/project/package merge and source metadata; +- shortcut validation on writes; +- bounded icon verification and data-URL resolution; +- atomic apply, reset, and rollback; +- capability-protected RPC handling. + +Vite, Webpack, and Next construct the same engine and register it with their existing Agentic React bridge. + +### RPC operations + +Keep the browser-to-Node request surface fixed and typed: + +```text +settings:get-effective +settings:update-shortcuts +settings:reset-shortcut +settings:reset-shortcuts +settings:apply-icon +settings:reset-icon +``` + +Requests never accept a filesystem path. Every mutation requires the injected session capability. An unauthorized response must not disclose global settings. + +## Development and Production Boundaries + +Adapter integration remains development-only: + +- Vite runs the plugin with `apply: 'serve'`; +- Next remains gated by development client compilation; +- Webpack follows its existing development injection gate; +- production builds do not start the Agentic React bridge or inject settings capability data. + +Direct `@agentic-react/core` imports remain an intentional runtime API outside the adapter production guarantee. + +## Verification Strategy + +### Unit and adapter tests + +Cover: + +- global > project > package precedence; +- injected settings-root isolation; +- corrupt settings fallback; +- normalized shortcut writes, duplicate rejection, and per-action reset; +- unauthorized RPC non-disclosure; +- icon format/size validation, reload resolution, rollback, and reset ordering; +- crop center, drag bounds, zoom bounds, and 90-degree rotation; +- Vite, Webpack, and Next bootstrap the same effective settings shape. + +### Browser E2E + +The Vite playground owns the full interaction matrix: + +- all toolbox and crop-modal activation events stay out of host bubble handlers; +- a transient host popover remains open during toolbox interaction; +- single capture leaves clipboard unchanged until Done; +- the Done button and default Enter shortcut copy the pending single selection; +- multiselect remains additive and copies only on Done; +- Escape before capture and after pending single/multi selection discards pending state, consumes keydown and keyup, and leaves clipboard unchanged; +- shortcut recording rejects duplicates, persists to an isolated global store, survives reload, and resets per action; +- icon upload, crop, zoom, rotate, Apply, reload, and Reset work end to end; +- reset reveals the project `iconUrl` default. + +Smaller adapter tests cover shared engine/bootstrap behavior without duplicating the full browser suite. + +## Acceptance Criteria + +- Toolbox UI interaction does not close host UI through bubbled activation events. +- Single select and multiselect copy only after explicit Done confirmation. +- The configured Done shortcut works for pending single and multiselect state. +- Escape is reserved, consumes the full key cycle during selection ownership, cancels without copying, and preserves committed context. +- Users can configure and individually reset four shortcuts from Settings. +- Invalid or duplicate shortcuts cannot be persisted through either UI or direct settings RPC. +- Users can upload, crop, rotate, zoom, apply, reload, and reset a global launcher icon. +- Global settings apply across projects, origins, ports, and supported adapters on the same machine. +- Project configuration supplies defaults only; it does not own user settings. +- Runtime-only use fails closed for persistence without a browser-storage fallback. +- Vite, Webpack, and Next reuse the existing Agentic React bridge and share the same settings engine. +- Tests use an isolated root and never mutate the real `~/.agentic-react` directory. diff --git a/package.json b/package.json index d5fe6c9..7cd1d9d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "changeset:status": "changeset status --since=origin/main", "version-packages": "changeset version", "release": "changeset publish", - "test": "pnpm run test:e2e", + "test": "pnpm run test:unit && pnpm run test:e2e", + "test:unit": "pnpm --filter @agentic-react/core run test && pnpm --filter @agentic-react/next run test && pnpm --filter @agentic-react/webpack run test", "test:e2e": "node scripts/run-e2e.mjs", "test:release-smoke": "node scripts/release-smoke-test.mjs" }, diff --git a/packages/core/README.md b/packages/core/README.md index 1f1c03c..f14621f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -19,6 +19,28 @@ For full local-dev MCP features, install an adapter instead: Adapters depend on `@agentic-react/core` internally and add runtime injection, bridge transport, MCP endpoints, and source-root context. +## Settings Availability + +`@agentic-react/core` contains the browser UI and shared types for toolbox Settings, but persistent Settings require an adapter-hosted bridge. Direct runtime usage cannot read or write `~/.agentic-react/settings.json`, cannot persist a custom toolbox icon, and cannot save shortcut changes unless you provide your own `AgenticReactSettingsClient`. + +Without that bridge, the selection toolkit can still use package defaults and code-provided `toolkit` defaults. The package shortcut defaults are `Ctrl+Alt+Shift+S` for single select, `Ctrl+Alt+Shift+M` for multiselect, `Ctrl+Alt+Shift+A` for toggling the toolbox, and `Enter` for Done. `Escape` is reserved for cancelling pending selection. + +Adapter-backed Settings resolve values as: + +```text +global user override + > project configuration default + > package default +``` + +Project defaults are supplied by adapters through `toolkit.settings.shortcuts` and `toolkit.iconUrl`. Runtime-only integrations should treat those as code defaults, not persisted user preferences. + +## Selection Confirmation + +Single selection is pending until the user confirms it. Clicking a target captures and highlights the context, but does not copy it. Clicking Done, or pressing the configured Done shortcut, copies that pending context and commits it as the last selection. If clipboard writing fails, the pending context stays available for retry. + +Multiselect collects pending contexts until Done copies the complete set. `Escape` cancels pending single and multiselect contexts without copying and leaves the last committed selection intact. + ## Tuning Modal Runtime API The browser runtime exposes the tuning modal through the selection toolkit and `window.__AGENTIC_REACT__`. diff --git a/packages/core/package.json b/packages/core/package.json index e1cc38b..a668de6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,7 +7,8 @@ "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" + "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", + "test": "pnpm run build && node --test test/*.test.mjs" }, "keywords": [ "agentic-react", @@ -64,6 +65,10 @@ "types": "./dist/select.d.ts", "default": "./dist/select.js" }, + "./source-lookup": { + "types": "./dist/source_lookup.d.ts", + "default": "./dist/source_lookup.js" + }, "./shared/const": { "types": "./dist/shared/const.d.ts", "default": "./dist/shared/const.js" diff --git a/packages/core/src/bridge/server.ts b/packages/core/src/bridge/server.ts index 467c009..8b341c8 100644 --- a/packages/core/src/bridge/server.ts +++ b/packages/core/src/bridge/server.ts @@ -22,6 +22,10 @@ interface RuntimeBridgeRequestOptions { timeoutMs?: number; } +type BrowserBridgeRequestHandler = ( + payload: unknown, +) => unknown | Promise; + const isBridgeMessage = (value: unknown): value is BridgeMessage => { if (!value || typeof value !== 'object') { return false; @@ -33,12 +37,25 @@ const isBridgeMessage = (value: unknown): value is BridgeMessage => { ); }; +const MAX_BRIDGE_MESSAGE_BYTES = 2 * 1024 * 1024; + export class RuntimeBridgeServer { private wsServer: WebSocketServer | null = null; private activeSocket: WebSocket | null = null; private readonly pendingRequests = new Map(); + private readonly requestHandlers = new Map< + BridgeRequestEvent, + BrowserBridgeRequestHandler + >(); private readonly sockets = new Set(); + registerHandler( + event: BridgeRequestEvent, + handler: BrowserBridgeRequestHandler, + ) { + this.requestHandlers.set(event, handler); + } + attach(httpServer: any) { this.wsServer = new WebSocketServer({ noServer: true }); @@ -61,13 +78,22 @@ export class RuntimeBridgeServer { websocket.on('message', (messageBuffer) => { try { - const parsedMessage = JSON.parse(messageBuffer.toString()) as unknown; + const messageText = messageBuffer.toString(); + if ( + Buffer.byteLength(messageText, 'utf8') > MAX_BRIDGE_MESSAGE_BYTES + ) { + websocket.close(1009, 'Bridge message too large'); + return; + } + + const parsedMessage = JSON.parse(messageText) as unknown; if (!isBridgeMessage(parsedMessage)) { return; } - if (parsedMessage.type !== 'bridge:response') { + if (parsedMessage.type === 'bridge:request') { + void this.handleBrowserRequest(websocket, parsedMessage); return; } @@ -232,6 +258,49 @@ export class RuntimeBridgeServer { }); } + private async handleBrowserRequest( + socket: WebSocket, + message: Extract, + ) { + const handler = this.requestHandlers.get(message.event); + if (!handler) { + this.sendResponse(socket, { + type: 'bridge:response', + id: message.id, + ok: false, + error: `Unsupported bridge event: ${message.event}`, + }); + return; + } + + try { + const payload = await handler(message.payload); + this.sendResponse(socket, { + type: 'bridge:response', + id: message.id, + ok: true, + payload, + }); + } catch (error) { + this.sendResponse(socket, { + type: 'bridge:response', + id: message.id, + ok: false, + error: error instanceof Error ? error.message : 'Bridge request failed', + }); + } + } + + private sendResponse( + socket: WebSocket, + message: Extract, + ) { + if (socket.readyState !== socket.OPEN) { + return; + } + socket.send(JSON.stringify(message)); + } + private getOpenSockets(): WebSocket[] { const sockets = Array.from(this.sockets).filter( (socket) => socket.readyState === socket.OPEN, diff --git a/packages/core/src/core/settings/browser.ts b/packages/core/src/core/settings/browser.ts new file mode 100644 index 0000000..79e5762 --- /dev/null +++ b/packages/core/src/core/settings/browser.ts @@ -0,0 +1,134 @@ +import type { + AgenticReactSettingsBootstrap, + AgenticReactSettingsCapability, + AgenticReactSettingsError, + AgenticReactSettingsRpcResult, + AgenticReactSettingsSnapshot, + AgenticReactShortcutKey, + AgenticReactShortcutSettings, + AgenticReactToolboxIconMime, +} from '../../shared/types.js'; +import { createUnavailableSettingsSnapshot } from './schema.js'; + +export type BrowserSettingsBridgeRequest = ( + event: + | 'settings:get-effective' + | 'settings:update-shortcuts' + | 'settings:reset-shortcut' + | 'settings:apply-icon' + | 'settings:reset-icon' + | 'settings:reset-shortcuts', + payload: unknown, + timeoutMs?: number, +) => Promise; + +export interface BrowserSettingsClientOptions { + initialSettings?: AgenticReactSettingsBootstrap; + request: BrowserSettingsBridgeRequest; +} + +const DEFAULT_SETTINGS_RPC_TIMEOUT_MS = 10000; + +const createSettingsError = ( + code: AgenticReactSettingsError['code'], + message: string, +): AgenticReactSettingsError => ({ + code, + message, +}); + +export const createAgenticReactSettingsClient = ({ + initialSettings, + request, +}: BrowserSettingsClientOptions) => { + let snapshot: AgenticReactSettingsSnapshot = + initialSettings || createUnavailableSettingsSnapshot(); + const capability: AgenticReactSettingsCapability = + initialSettings?.capability || { + available: false, + reason: 'Settings capability was not provided by the dev adapter.', + }; + + const requestSettings = async ( + event: Parameters[0], + payload: Record = {}, + ): Promise => { + if (!capability.available || !capability.token) { + return { + success: false, + ...snapshot, + error: createSettingsError( + 'settings_unavailable', + capability.reason || 'Agentic React settings are unavailable.', + ), + }; + } + + try { + const result = await request( + event, + { + ...payload, + token: capability.token, + }, + DEFAULT_SETTINGS_RPC_TIMEOUT_MS, + ); + if (!isSettingsRpcResult(result)) { + return { + success: false, + ...snapshot, + error: createSettingsError( + 'invalid_payload', + 'Settings bridge returned an invalid response.', + ), + }; + } + + snapshot = { + effectiveSettings: result.effectiveSettings, + sources: result.sources, + errors: result.errors, + }; + return result; + } catch (error) { + return { + success: false, + ...snapshot, + error: createSettingsError( + 'settings_unavailable', + error instanceof Error ? error.message : 'Settings bridge failed.', + ), + }; + } + }; + + return { + getEffectiveSettings: () => requestSettings('settings:get-effective'), + updateShortcuts: (shortcuts: Partial) => + requestSettings('settings:update-shortcuts', { shortcuts }), + resetShortcut: (key: AgenticReactShortcutKey) => + requestSettings('settings:reset-shortcut', { key }), + applyIcon: (input: { data: string; mime?: AgenticReactToolboxIconMime }) => + requestSettings('settings:apply-icon', { icon: input }), + resetIcon: () => requestSettings('settings:reset-icon'), + resetShortcuts: () => requestSettings('settings:reset-shortcuts'), + getCachedSnapshot: () => snapshot, + getCapability: () => ({ ...capability }), + }; +}; + +const isSettingsRpcResult = ( + value: unknown, +): value is AgenticReactSettingsRpcResult => { + if (!value || typeof value !== 'object') { + return false; + } + + const result = value as Partial; + return ( + typeof result.success === 'boolean' && + !!result.effectiveSettings && + !!result.sources && + Array.isArray(result.errors) + ); +}; diff --git a/packages/core/src/core/settings/icon_modal.ts b/packages/core/src/core/settings/icon_modal.ts new file mode 100644 index 0000000..1a8015d --- /dev/null +++ b/packages/core/src/core/settings/icon_modal.ts @@ -0,0 +1,345 @@ +import type { AgenticReactToolboxIconMime } from '../../shared/types.js'; +import { + type ImageCropState, + type ImageCropperSource, + createDefaultImageCropState, + decodeImageCropperSource, + dragImageCropState, + encodeImageCrop, + isImageCropperError, + renderImageCropToCanvas, + rotateImageCropState, + zoomImageCropState, +} from './image_cropper.js'; +import { stopHostActivationEvents } from './ui.js'; + +export interface ToolboxIconCropperOptions { + file: File; + zIndex: number; + restoreFocusTo: HTMLElement; + onApply: (icon: { + data: string; + mime: AgenticReactToolboxIconMime; + }) => Promise; + onCancel?: () => void; + onError?: (message: string) => void; +} + +const PREVIEW_SIZE = 256; + +export const openToolboxIconCropper = async ({ + file, + zIndex, + restoreFocusTo, + onApply, + onCancel, + onError, +}: ToolboxIconCropperOptions): Promise => { + let source: ImageCropperSource; + try { + source = await decodeImageCropperSource(file); + } catch (error) { + const message = getCropperErrorMessage(error); + onError?.(message); + throw error; + } + + createToolboxIconCropperModal({ + source, + zIndex, + restoreFocusTo, + onApply, + onCancel, + onError, + }); +}; + +const createToolboxIconCropperModal = ({ + source, + zIndex, + restoreFocusTo, + onApply, + onCancel, + onError, +}: Omit & { + source: ImageCropperSource; +}) => { + let state: ImageCropState = createDefaultImageCropState(source); + let dragStart: { x: number; y: number; state: ImageCropState } | null = null; + let isApplying = false; + const previousActiveElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : restoreFocusTo; + + const root = document.createElement('div'); + const dialog = document.createElement('div'); + const title = document.createElement('div'); + const previewFrame = document.createElement('div'); + const previewCanvas = document.createElement('canvas'); + const finalPreview = document.createElement('canvas'); + const zoomLabel = document.createElement('label'); + const zoomInput = document.createElement('input'); + const rotateRow = document.createElement('div'); + const rotateLeftButton = createModalButton('Rotate left', true); + const rotateRightButton = createModalButton('Rotate right', true); + const errorElement = document.createElement('div'); + const footer = document.createElement('div'); + const cancelButton = createModalButton('Cancel', true); + const applyButton = createModalButton('Apply', false); + + root.style.position = 'fixed'; + root.style.inset = '0'; + root.style.zIndex = String(zIndex); + root.style.display = 'grid'; + root.style.placeItems = 'center'; + root.style.padding = '16px'; + root.style.background = 'rgba(15, 23, 42, 0.42)'; + root.style.fontFamily = 'ui-sans-serif, system-ui, sans-serif'; + stopHostActivationEvents(root); + root.addEventListener('click', (event) => { + if (event.target === root) { + close(false); + } + }); + + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-labelledby', 'agentic-react-icon-crop-title'); + dialog.tabIndex = -1; + dialog.style.width = 'min(360px, calc(100vw - 32px))'; + dialog.style.background = '#ffffff'; + dialog.style.border = '1px solid rgba(15, 23, 42, 0.16)'; + dialog.style.borderRadius = '10px'; + dialog.style.boxShadow = '0 24px 70px rgba(15, 23, 42, 0.34)'; + dialog.style.padding = '14px'; + dialog.style.display = 'flex'; + dialog.style.flexDirection = 'column'; + dialog.style.gap = '12px'; + dialog.style.color = '#111827'; + + title.id = 'agentic-react-icon-crop-title'; + title.textContent = 'Crop toolbox icon'; + title.style.fontSize = '14px'; + title.style.fontWeight = '800'; + + previewFrame.style.width = `${PREVIEW_SIZE}px`; + previewFrame.style.maxWidth = '100%'; + previewFrame.style.aspectRatio = '1 / 1'; + previewFrame.style.margin = '0 auto'; + previewFrame.style.borderRadius = '999px'; + previewFrame.style.overflow = 'hidden'; + previewFrame.style.cursor = 'grab'; + previewFrame.style.touchAction = 'none'; + previewFrame.style.boxShadow = + '0 0 0 1px rgba(15, 23, 42, 0.16), 0 12px 30px rgba(15, 23, 42, 0.18)'; + + previewCanvas.style.width = '100%'; + previewCanvas.style.height = '100%'; + previewCanvas.style.display = 'block'; + previewFrame.appendChild(previewCanvas); + + zoomInput.type = 'range'; + zoomInput.min = String(state.zoom); + zoomInput.max = String(state.zoom * 4); + zoomInput.step = '0.01'; + zoomInput.value = String(state.zoom); + zoomInput.setAttribute('aria-label', 'Zoom icon crop'); + zoomLabel.textContent = 'Zoom'; + zoomLabel.style.display = 'grid'; + zoomLabel.style.gridTemplateColumns = '52px minmax(0, 1fr)'; + zoomLabel.style.alignItems = 'center'; + zoomLabel.style.gap = '8px'; + zoomLabel.style.fontSize = '12px'; + zoomLabel.style.fontWeight = '700'; + zoomLabel.appendChild(zoomInput); + + finalPreview.style.width = '48px'; + finalPreview.style.height = '48px'; + finalPreview.style.borderRadius = '999px'; + finalPreview.style.boxShadow = '0 0 0 1px rgba(15, 23, 42, 0.2)'; + rotateLeftButton.textContent = 'Rotate left'; + rotateRightButton.textContent = 'Rotate right'; + rotateRow.style.display = 'grid'; + rotateRow.style.gridTemplateColumns = '1fr 1fr auto'; + rotateRow.style.gap = '8px'; + rotateRow.style.alignItems = 'center'; + rotateRow.appendChild(rotateLeftButton); + rotateRow.appendChild(rotateRightButton); + rotateRow.appendChild(finalPreview); + + errorElement.setAttribute('role', 'status'); + errorElement.setAttribute('aria-live', 'polite'); + errorElement.style.minHeight = '16px'; + errorElement.style.fontSize = '12px'; + errorElement.style.color = '#b91c1c'; + + cancelButton.textContent = 'Cancel'; + applyButton.textContent = 'Apply'; + footer.style.display = 'grid'; + footer.style.gridTemplateColumns = '1fr 1fr'; + footer.style.gap = '8px'; + footer.appendChild(cancelButton); + footer.appendChild(applyButton); + + dialog.appendChild(title); + dialog.appendChild(previewFrame); + dialog.appendChild(zoomLabel); + dialog.appendChild(rotateRow); + dialog.appendChild(errorElement); + dialog.appendChild(footer); + root.appendChild(dialog); + document.body.appendChild(root); + + const setError = (message: string) => { + errorElement.textContent = message; + onError?.(message); + }; + const render = () => { + renderImageCropToCanvas(previewCanvas, source, state, { + size: PREVIEW_SIZE, + pixelRatio: window.devicePixelRatio || 1, + }); + renderImageCropToCanvas(finalPreview, source, state, { + size: 48, + pixelRatio: window.devicePixelRatio || 1, + }); + }; + const close = (applied: boolean) => { + root.remove(); + source.dispose(); + const restoreTarget = restoreFocusTo.isConnected + ? restoreFocusTo + : previousActiveElement; + restoreTarget?.focus?.(); + if (!applied) { + onCancel?.(); + } + }; + + previewFrame.addEventListener('pointerdown', (event) => { + event.preventDefault(); + previewFrame.setPointerCapture(event.pointerId); + previewFrame.style.cursor = 'grabbing'; + dragStart = { x: event.clientX, y: event.clientY, state }; + }); + previewFrame.addEventListener('pointermove', (event) => { + if (!dragStart) return; + event.preventDefault(); + state = dragImageCropState( + dragStart.state, + { + deltaX: event.clientX - dragStart.x, + deltaY: event.clientY - dragStart.y, + }, + source, + { viewportSize: PREVIEW_SIZE }, + ); + render(); + }); + previewFrame.addEventListener('pointerup', (event) => { + previewFrame.releasePointerCapture(event.pointerId); + previewFrame.style.cursor = 'grab'; + dragStart = null; + }); + previewFrame.addEventListener('pointercancel', () => { + previewFrame.style.cursor = 'grab'; + dragStart = null; + }); + zoomInput.addEventListener('input', () => { + state = zoomImageCropState(state, Number(zoomInput.value), source); + render(); + }); + rotateLeftButton.addEventListener('click', () => { + state = rotateImageCropState(state, 'left', source); + render(); + }); + rotateRightButton.addEventListener('click', () => { + state = rotateImageCropState(state, 'right', source); + render(); + }); + cancelButton.addEventListener('click', () => close(false)); + applyButton.addEventListener('click', () => { + if (isApplying) return; + isApplying = true; + applyButton.disabled = true; + applyButton.textContent = 'Applying...'; + setError(''); + void encodeImageCrop(source, state) + .then((result) => + onApply({ + data: result.data, + mime: result.mime, + }), + ) + .then(() => close(true)) + .catch((error) => { + isApplying = false; + applyButton.disabled = false; + applyButton.textContent = 'Apply'; + setError(getCropperErrorMessage(error)); + }); + }); + root.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + close(false); + return; + } + trapModalFocus(event, dialog); + }); + + render(); + dialog.focus(); +}; + +const createModalButton = ( + label: string, + muted: boolean, +): HTMLButtonElement => { + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('aria-label', label); + button.style.height = '34px'; + button.style.border = '1px solid rgba(15, 23, 42, 0.16)'; + button.style.borderRadius = '8px'; + button.style.background = muted ? '#f8fafc' : '#111827'; + button.style.color = muted ? '#111827' : '#ffffff'; + button.style.fontSize = '12px'; + button.style.fontWeight = '700'; + button.style.cursor = 'pointer'; + return button; +}; + +const trapModalFocus = (event: KeyboardEvent, container: HTMLElement) => { + if (event.key !== 'Tab') return; + + const focusable = Array.from( + container.querySelectorAll( + 'button, input, select, textarea, [href], [tabindex]:not([tabindex="-1"])', + ), + ).filter((element) => !element.hasAttribute('disabled')); + if (focusable.length === 0) { + event.preventDefault(); + container.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +}; + +const getCropperErrorMessage = (error: unknown): string => { + if (isImageCropperError(error)) { + return error.message; + } + return error instanceof Error ? error.message : 'Failed to process image.'; +}; diff --git a/packages/core/src/core/settings/image_cropper.ts b/packages/core/src/core/settings/image_cropper.ts new file mode 100644 index 0000000..15ae80b --- /dev/null +++ b/packages/core/src/core/settings/image_cropper.ts @@ -0,0 +1,642 @@ +export type ImageCropperInputMime = + | 'image/png' + | 'image/jpeg' + | 'image/webp' + | 'image/gif'; + +export type ImageCropperOutputMime = 'image/webp' | 'image/png'; + +export type ImageCropperRotation = 0 | 90 | 180 | 270; + +export type ImageCropperErrorCode = + | 'unsupported_type' + | 'source_too_large' + | 'image_too_large' + | 'decode_failed' + | 'encode_failed'; + +export interface ImageCropperStructuredError { + code: ImageCropperErrorCode; + message: string; + detail?: string; +} + +export interface ImageCropperDimensions { + width: number; + height: number; +} + +export interface ImageCropState { + offsetX: number; + offsetY: number; + zoom: number; + rotation: ImageCropperRotation; +} + +export interface ImageCropperSource { + image: ImageBitmap | HTMLCanvasElement; + width: number; + height: number; + mime: ImageCropperInputMime; + dispose: () => void; +} + +export interface DecodeImageCropperSourceOptions { + maxBytes?: number; + maxDimension?: number; + maxPixels?: number; +} + +export interface CreateImageCropStateOptions { + outputSize?: number; + rotation?: ImageCropperRotation; +} + +export interface ImageCropDragDelta { + deltaX: number; + deltaY: number; +} + +export interface ImageCropViewportOptions { + viewportSize?: number; + outputSize?: number; +} + +export interface ImageCropRenderOptions { + size?: number; + pixelRatio?: number; + smoothingQuality?: ImageSmoothingQuality; +} + +export interface ImageCropEncodeOptions { + size?: number; + maxBytes?: number; + webpQuality?: number; + pngQuality?: number; +} + +export interface ImageCropEncodeResult { + data: string; + mime: ImageCropperOutputMime; + previewUrl: string; +} + +const DEFAULT_OUTPUT_SIZE = 256; +const MAX_SOURCE_BYTES = 20 * 1024 * 1024; +const MAX_SOURCE_DIMENSION = 8192; +const MAX_SOURCE_PIXELS = 40 * 1000 * 1000; +const MAX_ENCODED_BYTES = 1024 * 1024; +const SVG_SNIFF_BYTES = 1024; +const BASE64_CHUNK_SIZE = 0x8000; + +const SUPPORTED_INPUT_MIMES: ImageCropperInputMime[] = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +]; + +export class ImageCropperError + extends Error + implements ImageCropperStructuredError +{ + readonly code: ImageCropperErrorCode; + readonly detail?: string; + + constructor(error: ImageCropperStructuredError) { + super(error.message); + this.name = 'ImageCropperError'; + this.code = error.code; + this.detail = error.detail; + } +} + +export const isImageCropperError = ( + error: unknown, +): error is ImageCropperError => + error instanceof ImageCropperError || + (!!error && + typeof error === 'object' && + typeof (error as Partial).code === 'string' && + typeof (error as Partial).message === 'string'); + +export const createImageCropperError = ( + code: ImageCropperErrorCode, + message: string, + detail?: string, +): ImageCropperError => + new ImageCropperError({ + code, + message, + ...(detail ? { detail } : {}), + }); + +export const validateImageCropperInput = async ( + input: Blob, + options: DecodeImageCropperSourceOptions = {}, +): Promise => { + const maxBytes = options.maxBytes ?? MAX_SOURCE_BYTES; + if (input.size <= 0) { + throw createImageCropperError( + 'unsupported_type', + 'Choose a PNG, JPEG, WebP, or GIF image.', + ); + } + if (input.size > maxBytes) { + throw createImageCropperError( + 'source_too_large', + 'Image must be 20 MB or smaller.', + ); + } + + const head = new Uint8Array( + await input.slice(0, SVG_SNIFF_BYTES).arrayBuffer(), + ); + if (isSvgInput(input, head)) { + throw createImageCropperError( + 'unsupported_type', + 'SVG images are not supported. Choose a raster PNG, JPEG, WebP, or GIF.', + ); + } + + const mime = detectRasterMime(input, head); + if (!mime) { + throw createImageCropperError( + 'unsupported_type', + 'Choose a PNG, JPEG, WebP, or GIF image.', + ); + } + + return mime; +}; + +export const decodeImageCropperSource = async ( + input: Blob, + options: DecodeImageCropperSourceOptions = {}, +): Promise => { + const mime = await validateImageCropperInput(input, options); + let bitmapDecodeError: unknown; + + if (typeof createImageBitmap === 'function') { + try { + const bitmap = await createImageBitmap(input, { + imageOrientation: 'from-image', + } as ImageBitmapOptions); + try { + validateDecodedDimensions(bitmap, options); + } catch (error) { + bitmap.close(); + throw error; + } + return { + image: bitmap, + width: bitmap.width, + height: bitmap.height, + mime, + dispose: () => bitmap.close(), + }; + } catch (error) { + if (isImageCropperError(error)) { + throw error; + } + bitmapDecodeError = error; + } + } + + try { + return await decodeImageWithElement(input, mime, options); + } catch (error) { + if (isImageCropperError(error)) { + throw error; + } + throw createImageCropperError( + 'decode_failed', + 'Image could not be decoded. Choose a different PNG, JPEG, WebP, or GIF.', + getDecodeFailureDetail(error, bitmapDecodeError), + ); + } +}; + +export const getRotatedImageDimensions = ( + dimensions: ImageCropperDimensions, + rotation: ImageCropperRotation, +): ImageCropperDimensions => + rotation === 90 || rotation === 270 + ? { width: dimensions.height, height: dimensions.width } + : { width: dimensions.width, height: dimensions.height }; + +export const getImageCropCoverZoom = ( + dimensions: ImageCropperDimensions, + rotation: ImageCropperRotation = 0, + outputSize = DEFAULT_OUTPUT_SIZE, +): number => { + const rotated = getRotatedImageDimensions(dimensions, rotation); + return Math.max(outputSize / rotated.width, outputSize / rotated.height); +}; + +export const createDefaultImageCropState = ( + dimensions: ImageCropperDimensions, + options: CreateImageCropStateOptions = {}, +): ImageCropState => { + const outputSize = options.outputSize ?? DEFAULT_OUTPUT_SIZE; + const rotation = normalizeRotation(options.rotation ?? 0); + return { + offsetX: 0, + offsetY: 0, + zoom: getImageCropCoverZoom(dimensions, rotation, outputSize), + rotation, + }; +}; + +export const normalizeImageCropState = ( + state: ImageCropState, + dimensions: ImageCropperDimensions, + outputSize = DEFAULT_OUTPUT_SIZE, +): ImageCropState => { + const rotation = normalizeRotation(state.rotation); + const coverZoom = getImageCropCoverZoom(dimensions, rotation, outputSize); + const zoom = Math.max( + Number.isFinite(state.zoom) ? state.zoom : coverZoom, + coverZoom, + ); + const limits = getImageCropOffsetLimits( + dimensions, + rotation, + zoom, + outputSize, + ); + + return { + offsetX: clampFinite(state.offsetX, -limits.x, limits.x), + offsetY: clampFinite(state.offsetY, -limits.y, limits.y), + zoom, + rotation, + }; +}; + +export const dragImageCropState = ( + state: ImageCropState, + delta: ImageCropDragDelta, + dimensions: ImageCropperDimensions, + options: ImageCropViewportOptions = {}, +): ImageCropState => { + const outputSize = options.outputSize ?? DEFAULT_OUTPUT_SIZE; + const viewportSize = options.viewportSize ?? outputSize; + const scale = outputSize / viewportSize; + return normalizeImageCropState( + { + ...state, + offsetX: state.offsetX + delta.deltaX * scale, + offsetY: state.offsetY + delta.deltaY * scale, + }, + dimensions, + outputSize, + ); +}; + +export const zoomImageCropState = ( + state: ImageCropState, + zoom: number, + dimensions: ImageCropperDimensions, + outputSize = DEFAULT_OUTPUT_SIZE, +): ImageCropState => + normalizeImageCropState( + { + ...state, + zoom, + }, + dimensions, + outputSize, + ); + +export const rotateImageCropState = ( + state: ImageCropState, + direction: 'left' | 'right', + dimensions: ImageCropperDimensions, + outputSize = DEFAULT_OUTPUT_SIZE, +): ImageCropState => { + const nextRotation = normalizeRotation( + state.rotation + (direction === 'left' ? -90 : 90), + ); + return normalizeImageCropState( + { + ...state, + rotation: nextRotation, + }, + dimensions, + outputSize, + ); +}; + +export const renderImageCropToCanvas = ( + canvas: HTMLCanvasElement, + source: ImageCropperSource, + state: ImageCropState, + options: ImageCropRenderOptions = {}, +): void => { + const logicalSize = options.size ?? DEFAULT_OUTPUT_SIZE; + const pixelRatio = options.pixelRatio ?? globalThis.devicePixelRatio ?? 1; + const backingSize = Math.max(1, Math.round(logicalSize * pixelRatio)); + + if (canvas.width !== backingSize) { + canvas.width = backingSize; + } + if (canvas.height !== backingSize) { + canvas.height = backingSize; + } + + const context = canvas.getContext('2d'); + if (!context) { + throw createImageCropperError( + 'encode_failed', + 'Canvas rendering is unavailable in this browser.', + ); + } + + const normalized = normalizeImageCropState( + state, + source, + DEFAULT_OUTPUT_SIZE, + ); + const previewScale = logicalSize / DEFAULT_OUTPUT_SIZE; + + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + context.clearRect(0, 0, logicalSize, logicalSize); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = options.smoothingQuality ?? 'high'; + + context.save(); + context.translate( + logicalSize / 2 + normalized.offsetX * previewScale, + logicalSize / 2 + normalized.offsetY * previewScale, + ); + context.rotate((normalized.rotation * Math.PI) / 180); + context.scale(normalized.zoom * previewScale, normalized.zoom * previewScale); + context.drawImage( + source.image, + -source.width / 2, + -source.height / 2, + source.width, + source.height, + ); + context.restore(); +}; + +export const encodeImageCrop = async ( + source: ImageCropperSource, + state: ImageCropState, + options: ImageCropEncodeOptions = {}, +): Promise => { + const size = options.size ?? DEFAULT_OUTPUT_SIZE; + const maxBytes = options.maxBytes ?? MAX_ENCODED_BYTES; + const canvas = document.createElement('canvas'); + renderImageCropToCanvas(canvas, source, state, { + size, + pixelRatio: 1, + smoothingQuality: 'high', + }); + + const webp = await canvasToBlob( + canvas, + 'image/webp', + options.webpQuality ?? 0.92, + ); + if (webp && webp.type === 'image/webp' && webp.size <= maxBytes) { + return blobToEncodeResult(webp, 'image/webp'); + } + + const png = await canvasToBlob( + canvas, + 'image/png', + options.pngQuality ?? undefined, + ); + if (png && png.size <= maxBytes) { + return blobToEncodeResult(png, 'image/png'); + } + + throw createImageCropperError( + 'encode_failed', + 'Cropped image must encode to 1 MB or smaller.', + ); +}; + +const validateDecodedDimensions = ( + dimensions: ImageCropperDimensions, + options: DecodeImageCropperSourceOptions, +) => { + const maxDimension = options.maxDimension ?? MAX_SOURCE_DIMENSION; + const maxPixels = options.maxPixels ?? MAX_SOURCE_PIXELS; + if ( + dimensions.width <= 0 || + dimensions.height <= 0 || + dimensions.width > maxDimension || + dimensions.height > maxDimension || + dimensions.width * dimensions.height > maxPixels + ) { + throw createImageCropperError( + 'image_too_large', + 'Image dimensions must be 8192px or smaller on each side and 40 megapixels or less.', + ); + } +}; + +const decodeImageWithElement = async ( + input: Blob, + mime: ImageCropperInputMime, + options: DecodeImageCropperSourceOptions, +): Promise => { + const objectUrl = URL.createObjectURL(input); + const image = new Image(); + image.decoding = 'async'; + + try { + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => + reject(new Error('Browser image element rejected the selected image.')); + image.src = objectUrl; + }); + if (typeof image.decode === 'function') { + await image.decode(); + } + + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + validateDecodedDimensions({ width, height }, options); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Canvas 2D context is unavailable.'); + } + context.clearRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + URL.revokeObjectURL(objectUrl); + + return { + image: canvas, + width, + height, + mime, + dispose: () => { + canvas.width = 0; + canvas.height = 0; + }, + }; + } catch (error) { + URL.revokeObjectURL(objectUrl); + throw error; + } +}; + +const getDecodeFailureDetail = ( + fallbackError: unknown, + bitmapDecodeError: unknown, +): string => { + const fallbackDetail = + fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError); + if (!bitmapDecodeError) { + return fallbackDetail; + } + + const bitmapDetail = + bitmapDecodeError instanceof Error + ? bitmapDecodeError.message + : String(bitmapDecodeError); + return `createImageBitmap failed: ${bitmapDetail}; HTMLImageElement fallback failed: ${fallbackDetail}`; +}; + +const isSvgInput = (input: Blob, head: Uint8Array): boolean => { + const type = input.type.toLowerCase(); + const fileName = + typeof File !== 'undefined' && input instanceof File + ? input.name.trim().toLowerCase() + : ''; + if ( + type === 'image/svg+xml' || + fileName.endsWith('.svg') || + fileName.endsWith('.svgz') + ) { + return true; + } + + const text = asciiHead(head) + .toLowerCase() + .replace(/^\uFEFF/, '') + .trimStart(); + return ( + text.startsWith(' { + if (hasPngSignature(head)) { + return 'image/png'; + } + if (hasJpegSignature(head)) { + return 'image/jpeg'; + } + if (hasWebpSignature(head)) { + return 'image/webp'; + } + if (hasGifSignature(head)) { + return 'image/gif'; + } + + const mime = input.type.toLowerCase(); + return SUPPORTED_INPUT_MIMES.includes(mime as ImageCropperInputMime) + ? (mime as ImageCropperInputMime) + : null; +}; + +const hasPngSignature = (bytes: Uint8Array): boolean => + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a; + +const hasJpegSignature = (bytes: Uint8Array): boolean => + bytes.length >= 3 && + bytes[0] === 0xff && + bytes[1] === 0xd8 && + bytes[2] === 0xff; + +const hasWebpSignature = (bytes: Uint8Array): boolean => + bytes.length >= 12 && + asciiHead(bytes.slice(0, 4)) === 'RIFF' && + asciiHead(bytes.slice(8, 12)) === 'WEBP'; + +const hasGifSignature = (bytes: Uint8Array): boolean => { + const signature = asciiHead(bytes.slice(0, 6)); + return signature === 'GIF87a' || signature === 'GIF89a'; +}; + +const asciiHead = (bytes: Uint8Array): string => String.fromCharCode(...bytes); + +const getImageCropOffsetLimits = ( + dimensions: ImageCropperDimensions, + rotation: ImageCropperRotation, + zoom: number, + outputSize: number, +): { x: number; y: number } => { + const rotated = getRotatedImageDimensions(dimensions, rotation); + return { + x: Math.max(0, (rotated.width * zoom - outputSize) / 2), + y: Math.max(0, (rotated.height * zoom - outputSize) / 2), + }; +}; + +const normalizeRotation = (rotation: number): ImageCropperRotation => { + const normalized = (((Math.round(rotation / 90) * 90) % 360) + 360) % 360; + return normalized as ImageCropperRotation; +}; + +const clampFinite = (value: number, min: number, max: number): number => { + const finite = Number.isFinite(value) ? value : 0; + return Math.min(max, Math.max(min, finite)); +}; + +const canvasToBlob = ( + canvas: HTMLCanvasElement, + mime: ImageCropperOutputMime, + quality?: number, +): Promise => + new Promise((resolve) => { + canvas.toBlob(resolve, mime, quality); + }); + +const blobToEncodeResult = async ( + blob: Blob, + mime: ImageCropperOutputMime, +): Promise => { + const data = arrayBufferToBase64(await blob.arrayBuffer()); + return { + data, + mime, + previewUrl: `data:${mime};base64,${data}`, + }; +}; + +const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let index = 0; index < bytes.length; index += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode( + ...bytes.subarray(index, index + BASE64_CHUNK_SIZE), + ); + } + return btoa(binary); +}; diff --git a/packages/core/src/core/settings/node.ts b/packages/core/src/core/settings/node.ts new file mode 100644 index 0000000..27c8aeb --- /dev/null +++ b/packages/core/src/core/settings/node.ts @@ -0,0 +1,800 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { RuntimeBridgeServer } from '../../bridge/server.js'; +import type { + AgenticReactSettingsBootstrap, + AgenticReactSettingsError, + AgenticReactSettingsRpcResult, + AgenticReactSettingsSnapshot, + AgenticReactShortcutKey, + AgenticReactShortcutSettings, + AgenticReactToolboxIconMetadata, + AgenticReactToolboxIconMime, + AgenticReactToolkitConfig, +} from '../../shared/types.js'; +import { + AGENTIC_REACT_PNG_ICON_FILENAME, + AGENTIC_REACT_SETTINGS_DIRECTORY, + AGENTIC_REACT_SETTINGS_FILENAME, + AGENTIC_REACT_SETTINGS_SCHEMA_VERSION, + AGENTIC_REACT_SHORTCUT_KEYS, + AGENTIC_REACT_WEBP_ICON_FILENAME, + type PersistedAgenticReactSettings, + createUnavailableSettingsSnapshot, + getIconFilenameForMime, + isAllowedToolboxIconMime, + mergeSettings, + normalizePersistedSettingsForWrite, + sanitizeProjectSettingsDefaults, + validatePersistedSettings, +} from './schema.js'; +import { + normalizeShortcutString, + validateShortcutSettings, +} from './shortcuts.js'; + +export interface NodeSettingsStoreOptions { + homeDir?: string; + settingsRoot?: string; + projectToolkitConfig?: AgenticReactToolkitConfig; + fileSystem?: NodeSettingsStoreFileSystem; +} + +export interface AgenticReactSettingsEngineOptions + extends NodeSettingsStoreOptions { + token?: string; +} + +const MAX_SHORTCUT_PATCH_KEYS = AGENTIC_REACT_SHORTCUT_KEYS.length; +const MAX_ICON_BYTES = 1024 * 1024; +const MAX_ICON_ENCODED_CHARS = Math.ceil((MAX_ICON_BYTES * 4) / 3) + 128; +const NODE_SHORTCUT_PLATFORM = + process.platform === 'darwin' ? 'MacIntel' : 'Win32'; + +export interface NodeSettingsStoreFileSystem { + mkdirSync: typeof fs.mkdirSync; + readFileSync: typeof fs.readFileSync; + renameSync: typeof fs.renameSync; + rmSync: typeof fs.rmSync; + writeFileSync: typeof fs.writeFileSync; +} + +const createSettingsError = ( + code: AgenticReactSettingsError['code'], + message: string, + detail?: string, +): AgenticReactSettingsError => ({ + code, + message, + ...(detail ? { detail } : {}), +}); + +const isObjectRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +export class NodeSettingsStore { + private readonly settingsRoot: string; + private readonly projectToolkitConfig: AgenticReactToolkitConfig | undefined; + private readonly fileSystem: NodeSettingsStoreFileSystem; + + constructor(options: NodeSettingsStoreOptions = {}) { + this.settingsRoot = + options.settingsRoot || + path.join( + options.homeDir || os.homedir(), + AGENTIC_REACT_SETTINGS_DIRECTORY, + ); + this.projectToolkitConfig = options.projectToolkitConfig; + this.fileSystem = options.fileSystem || fs; + } + + getSettingsRootForTests(): string { + return this.settingsRoot; + } + + getSettingsFilePathForTests(): string { + return this.settingsFilePath; + } + + getSnapshot(): AgenticReactSettingsSnapshot { + const readResult = this.readPersistedSettings(); + const resolvedIcon = this.resolveGlobalToolboxIcon( + readResult.settings?.appearance?.toolboxIcon, + ); + return mergeSettings( + readResult.settings, + sanitizeProjectSettingsDefaults(this.projectToolkitConfig), + [...readResult.errors, ...resolvedIcon.errors], + resolvedIcon.url, + ); + } + + updateShortcuts(shortcuts: unknown): AgenticReactSettingsRpcResult { + const validation = validateShortcutPatch(shortcuts); + if (validation.success === false) { + return this.failure(validation.error); + } + + const readResult = this.readPersistedSettingsForWrite(); + const nextSettings = normalizePersistedSettingsForWrite({ + ...readResult.settings, + shortcuts: { + ...(readResult.settings.shortcuts || {}), + ...validation.shortcuts, + }, + }); + + const effectiveValidation = this.validateEffectiveShortcuts(nextSettings); + if (effectiveValidation) { + return this.failure(effectiveValidation); + } + + return this.writeSettingsResult(nextSettings); + } + + resetShortcuts(): AgenticReactSettingsRpcResult { + const readResult = this.readPersistedSettingsForWrite(); + const nextSettings = normalizePersistedSettingsForWrite({ + ...readResult.settings, + shortcuts: undefined, + }); + const effectiveValidation = this.validateEffectiveShortcuts(nextSettings); + if (effectiveValidation) { + return this.failure(effectiveValidation); + } + return this.writeSettingsResult(nextSettings); + } + + resetShortcut(key: unknown): AgenticReactSettingsRpcResult { + if (!AGENTIC_REACT_SHORTCUT_KEYS.includes(key as AgenticReactShortcutKey)) { + return this.failure( + createSettingsError( + 'invalid_payload', + 'Shortcut reset payload must include a known shortcut key.', + ), + ); + } + + const readResult = this.readPersistedSettingsForWrite(); + const nextShortcuts = { ...(readResult.settings.shortcuts || {}) }; + delete nextShortcuts[key as AgenticReactShortcutKey]; + const nextSettings = normalizePersistedSettingsForWrite({ + ...readResult.settings, + shortcuts: + Object.keys(nextShortcuts).length > 0 ? nextShortcuts : undefined, + }); + const effectiveValidation = this.validateEffectiveShortcuts(nextSettings); + if (effectiveValidation) { + return this.failure(effectiveValidation); + } + return this.writeSettingsResult(nextSettings); + } + + private validateEffectiveShortcuts( + settings: PersistedAgenticReactSettings, + ): AgenticReactSettingsError | null { + const effectiveSettings = mergeSettings( + settings, + sanitizeProjectSettingsDefaults(this.projectToolkitConfig), + ).effectiveSettings; + const validation = validateShortcutSettings( + effectiveSettings.shortcuts, + NODE_SHORTCUT_PLATFORM, + ); + if (validation.success === true) { + return null; + } + return createSettingsError( + 'invalid_payload', + `Invalid shortcut configuration: ${validation.reason}`, + ); + } + + applyIcon(input: unknown): AgenticReactSettingsRpcResult { + const validation = validateIconInput(input); + if (validation.success === false) { + return this.failure(validation.error); + } + + const filename = getIconFilenameForMime(validation.mime); + const iconPath = path.join(this.settingsRoot, filename); + const readResult = this.readPersistedSettingsForWrite(); + const previousIcon = readResult.settings.appearance?.toolboxIcon; + const previousIconBytes = this.readPreviousIconBytes(previousIcon); + + try { + atomicWriteFile(this.fileSystem, iconPath, validation.bytes); + } catch (error) { + return this.failure( + createSettingsError( + 'write_failed', + 'Failed to write Agentic React toolbox icon.', + error instanceof Error ? error.message : String(error), + ), + ); + } + + const nextSettings = normalizePersistedSettingsForWrite({ + ...readResult.settings, + appearance: { + ...(readResult.settings.appearance || {}), + toolboxIcon: { + filename, + mime: validation.mime, + updatedAt: Date.now(), + }, + }, + }); + + const writeError = this.writeSettings(nextSettings); + if (writeError) { + this.rollbackIconApply(filename, previousIcon, previousIconBytes); + return this.failure(writeError); + } + + this.removeIconFile( + filename === AGENTIC_REACT_WEBP_ICON_FILENAME + ? AGENTIC_REACT_PNG_ICON_FILENAME + : AGENTIC_REACT_WEBP_ICON_FILENAME, + ); + + return this.success(); + } + + resetIcon(): AgenticReactSettingsRpcResult { + const readResult = this.readPersistedSettingsForWrite(); + const nextSettings = normalizePersistedSettingsForWrite({ + ...readResult.settings, + appearance: undefined, + }); + const writeError = this.writeSettings(nextSettings); + if (writeError) { + return this.failure(writeError); + } + + this.removeIconFile(AGENTIC_REACT_WEBP_ICON_FILENAME); + this.removeIconFile(AGENTIC_REACT_PNG_ICON_FILENAME); + return this.success(); + } + + private get settingsFilePath(): string { + return path.join(this.settingsRoot, AGENTIC_REACT_SETTINGS_FILENAME); + } + + private readPersistedSettings(): { + settings: PersistedAgenticReactSettings | null; + errors: AgenticReactSettingsError[]; + } { + let source: string; + try { + source = this.fileSystem.readFileSync(this.settingsFilePath, 'utf8'); + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) { + return { + settings: { + schemaVersion: AGENTIC_REACT_SETTINGS_SCHEMA_VERSION, + }, + errors: [], + }; + } + return { + settings: null, + errors: [ + createSettingsError( + 'read_failed', + 'Failed to read Agentic React settings.', + error instanceof Error ? error.message : String(error), + ), + ], + }; + } + + try { + return validatePersistedSettings(JSON.parse(source)); + } catch (error) { + return { + settings: null, + errors: [ + createSettingsError( + 'invalid_settings', + 'Agentic React settings file contains invalid JSON.', + error instanceof Error ? error.message : String(error), + ), + ], + }; + } + } + + private readPersistedSettingsForWrite(): { + settings: PersistedAgenticReactSettings; + } { + const readResult = this.readPersistedSettings(); + return { + settings: + readResult.settings || + ({ + schemaVersion: AGENTIC_REACT_SETTINGS_SCHEMA_VERSION, + } satisfies PersistedAgenticReactSettings), + }; + } + + private writeSettingsResult( + settings: PersistedAgenticReactSettings, + ): AgenticReactSettingsRpcResult { + try { + const writeError = this.writeSettings(settings); + if (writeError) { + return this.failure(writeError); + } + } catch (error) { + return this.failure(this.createWriteSettingsError(error)); + } + + return this.success(); + } + + private failure( + error: AgenticReactSettingsError, + ): AgenticReactSettingsRpcResult { + return { + success: false, + ...this.getSnapshot(), + error, + }; + } + + private success(): AgenticReactSettingsRpcResult { + return { + success: true, + ...this.getSnapshot(), + }; + } + + private writeSettings( + settings: PersistedAgenticReactSettings, + ): AgenticReactSettingsError | null { + try { + atomicWriteJson(this.fileSystem, this.settingsFilePath, settings); + return null; + } catch (error) { + return this.createWriteSettingsError(error); + } + } + + private createWriteSettingsError(error: unknown): AgenticReactSettingsError { + return createSettingsError( + 'write_failed', + 'Failed to write Agentic React settings.', + error instanceof Error ? error.message : String(error), + ); + } + + private removeIconFile(filename: string) { + try { + this.fileSystem.rmSync(path.join(this.settingsRoot, filename), { + force: true, + }); + } catch (_error) { + // Best-effort cleanup; stale files are ignored by metadata allowlist. + } + } + + private resolveGlobalToolboxIcon( + metadata: AgenticReactToolboxIconMetadata | null | undefined, + ): { + url: string | null; + errors: AgenticReactSettingsError[]; + } { + if (!metadata) { + return { url: null, errors: [] }; + } + + const iconPath = path.join(this.settingsRoot, metadata.filename); + let bytes: Buffer; + try { + const rawBytes = this.fileSystem.readFileSync(iconPath); + bytes = Buffer.isBuffer(rawBytes) ? rawBytes : Buffer.from(rawBytes); + } catch (error) { + return { + url: null, + errors: [ + createSettingsError( + isNodeErrorCode(error, 'ENOENT') + ? 'invalid_settings' + : 'read_failed', + 'Configured Agentic React toolbox icon could not be read.', + metadata.filename, + ), + ], + }; + } + + if ( + bytes.length === 0 || + bytes.length > MAX_ICON_BYTES || + !hasExpectedMagicBytes(bytes, metadata.mime) + ) { + return { + url: null, + errors: [ + createSettingsError( + 'invalid_settings', + 'Configured Agentic React toolbox icon is invalid.', + metadata.filename, + ), + ], + }; + } + + return { + url: `data:${metadata.mime};base64,${bytes.toString('base64')}`, + errors: [], + }; + } + + private readPreviousIconBytes( + metadata: AgenticReactToolboxIconMetadata | null | undefined, + ): Buffer | null { + if (!metadata) { + return null; + } + + try { + const rawBytes = this.fileSystem.readFileSync( + path.join(this.settingsRoot, metadata.filename), + ); + return Buffer.isBuffer(rawBytes) ? Buffer.from(rawBytes) : null; + } catch (_error) { + return null; + } + } + + private rollbackIconApply( + newFilename: string, + previousIcon: AgenticReactToolboxIconMetadata | null | undefined, + previousIconBytes: Buffer | null, + ) { + if (previousIcon?.filename === newFilename && previousIconBytes) { + try { + atomicWriteFile( + this.fileSystem, + path.join(this.settingsRoot, previousIcon.filename), + previousIconBytes, + ); + return; + } catch (_error) { + // Fall through to removing the uncommitted icon target. + } + } + + this.removeIconFile(newFilename); + } +} + +export class AgenticReactSettingsEngine { + readonly token: string; + readonly store: NodeSettingsStore; + + constructor(options: AgenticReactSettingsEngineOptions = {}) { + this.token = options.token || randomBytes(32).toString('base64url'); + this.store = new NodeSettingsStore(options); + } + + getBootstrap(): AgenticReactSettingsBootstrap { + return { + ...this.store.getSnapshot(), + capability: { + available: true, + token: this.token, + }, + }; + } + + registerBridge(runtimeBridge: RuntimeBridgeServer) { + runtimeBridge.registerHandler('settings:get-effective', (payload) => + this.handleAuthorizedRequest(payload, () => ({ + success: true, + ...this.store.getSnapshot(), + })), + ); + runtimeBridge.registerHandler('settings:update-shortcuts', (payload) => + this.handleAuthorizedRequest(payload, (body) => + this.store.updateShortcuts(body.shortcuts), + ), + ); + runtimeBridge.registerHandler('settings:apply-icon', (payload) => + this.handleAuthorizedRequest(payload, (body) => + this.store.applyIcon(body.icon || body), + ), + ); + runtimeBridge.registerHandler('settings:reset-icon', (payload) => + this.handleAuthorizedRequest(payload, () => this.store.resetIcon()), + ); + runtimeBridge.registerHandler('settings:reset-shortcut', (payload) => + this.handleAuthorizedRequest(payload, (body) => + this.store.resetShortcut(body.key), + ), + ); + runtimeBridge.registerHandler('settings:reset-shortcuts', (payload) => + this.handleAuthorizedRequest(payload, () => this.store.resetShortcuts()), + ); + } + + private handleAuthorizedRequest( + payload: unknown, + handler: (body: Record) => AgenticReactSettingsRpcResult, + ): AgenticReactSettingsRpcResult { + if (!isObjectRecord(payload)) { + return this.unauthorizedFailure( + 'Settings RPC payload must be an object.', + ); + } + if (payload.token !== this.token) { + return this.unauthorizedFailure('Invalid Agentic React settings token.'); + } + return handler(payload); + } + + private unauthorizedFailure(message: string): AgenticReactSettingsRpcResult { + return { + success: false, + ...createUnauthorizedSettingsSnapshot(message), + error: createSettingsError('unauthorized', message), + }; + } +} + +export const createAgenticReactSettingsEngine = ( + options: AgenticReactSettingsEngineOptions = {}, +): AgenticReactSettingsEngine => new AgenticReactSettingsEngine(options); + +const validateShortcutPatch = ( + value: unknown, +): + | { + success: true; + shortcuts: Partial; + } + | { + success: false; + error: AgenticReactSettingsError; + } => { + if (!isObjectRecord(value)) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Shortcut update payload must be an object.', + ), + }; + } + + const entries = Object.entries(value); + if (entries.length === 0 || entries.length > MAX_SHORTCUT_PATCH_KEYS) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Shortcut update payload must include one or more known shortcuts.', + ), + }; + } + + const shortcuts: Partial = {}; + for (const [key, shortcut] of entries) { + if (!AGENTIC_REACT_SHORTCUT_KEYS.includes(key as never)) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + `Unsupported shortcut key: ${key}.`, + ), + }; + } + if ( + typeof shortcut !== 'string' || + shortcut.trim().length === 0 || + shortcut.trim().length > 80 + ) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + `Invalid shortcut value for ${key}.`, + ), + }; + } + const normalized = normalizeShortcutString( + shortcut.trim(), + NODE_SHORTCUT_PLATFORM, + ); + if (normalized.success === false) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + `Invalid shortcut value for ${key}: ${normalized.reason}`, + ), + }; + } + shortcuts[key as keyof AgenticReactShortcutSettings] = normalized.label; + } + + return { + success: true, + shortcuts, + }; +}; + +const validateIconInput = ( + input: unknown, +): + | { + success: true; + mime: AgenticReactToolboxIconMime; + bytes: Buffer; + } + | { + success: false; + error: AgenticReactSettingsError; + } => { + if (!isObjectRecord(input)) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Icon payload must be an object.', + ), + }; + } + + const rawData = input.data ?? input.encoded; + if (typeof rawData !== 'string' || rawData.length === 0) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Icon payload must include encoded image data.', + ), + }; + } + if (rawData.length > MAX_ICON_ENCODED_CHARS) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Icon payload is too large.', + ), + }; + } + + const dataUrlMatch = rawData.match( + /^data:(image\/(?:png|webp));base64,(.+)$/, + ); + const mime = dataUrlMatch?.[1] || input.mime; + const base64 = dataUrlMatch?.[2] || rawData; + + if (!isAllowedToolboxIconMime(mime)) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Icon payload must be PNG or WebP.', + ), + }; + } + if (dataUrlMatch && input.mime && input.mime !== mime) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Icon MIME type does not match the data URL.', + ), + }; + } + + let bytes: Buffer; + try { + bytes = Buffer.from(base64, 'base64'); + } catch (_error) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Icon payload is not valid base64.', + ), + }; + } + + if (bytes.length === 0 || bytes.length > MAX_ICON_BYTES) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Icon payload is too large.', + ), + }; + } + if (!hasExpectedMagicBytes(bytes, mime)) { + return { + success: false, + error: createSettingsError( + 'invalid_payload', + 'Icon payload does not match the declared image format.', + ), + }; + } + + return { + success: true, + mime, + bytes, + }; +}; + +const hasExpectedMagicBytes = ( + bytes: Buffer, + mime: AgenticReactToolboxIconMime, +): boolean => { + if (mime === 'image/png') { + return ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ); + } + + return ( + bytes.length >= 12 && + bytes.subarray(0, 4).toString('ascii') === 'RIFF' && + bytes.subarray(8, 12).toString('ascii') === 'WEBP' + ); +}; + +const atomicWriteJson = ( + fileSystem: NodeSettingsStoreFileSystem, + filePath: string, + value: unknown, +) => { + atomicWriteFile(fileSystem, filePath, `${JSON.stringify(value, null, 2)}\n`); +}; + +const atomicWriteFile = ( + fileSystem: NodeSettingsStoreFileSystem, + filePath: string, + value: string | Buffer, +) => { + const directory = path.dirname(filePath); + fileSystem.mkdirSync(directory, { recursive: true }); + const tempPath = path.join( + directory, + `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, + ); + + try { + fileSystem.writeFileSync(tempPath, value); + fileSystem.renameSync(tempPath, filePath); + } catch (error) { + try { + fileSystem.rmSync(tempPath, { force: true }); + } catch (_cleanupError) { + // noop + } + throw error; + } +}; + +const isNodeErrorCode = (error: unknown, code: string): boolean => + isObjectRecord(error) && error.code === code; + +const createUnauthorizedSettingsSnapshot = ( + message: string, +): AgenticReactSettingsSnapshot => createUnavailableSettingsSnapshot(message); diff --git a/packages/core/src/core/settings/schema.ts b/packages/core/src/core/settings/schema.ts new file mode 100644 index 0000000..ad06265 --- /dev/null +++ b/packages/core/src/core/settings/schema.ts @@ -0,0 +1,396 @@ +import type { + AgenticReactAppearanceSettings, + AgenticReactSettings, + AgenticReactSettingsError, + AgenticReactSettingsSnapshot, + AgenticReactSettingsSource, + AgenticReactSettingsSources, + AgenticReactShortcutKey, + AgenticReactShortcutSettings, + AgenticReactToolboxIconFilename, + AgenticReactToolboxIconMetadata, + AgenticReactToolboxIconMime, + AgenticReactToolkitConfig, +} from '../../shared/types.js'; + +export const AGENTIC_REACT_SETTINGS_SCHEMA_VERSION = 1; +export const AGENTIC_REACT_SETTINGS_DIRECTORY = '.agentic-react'; +export const AGENTIC_REACT_SETTINGS_FILENAME = 'settings.json'; +export const AGENTIC_REACT_WEBP_ICON_FILENAME = 'toolbox-icon.webp'; +export const AGENTIC_REACT_PNG_ICON_FILENAME = 'toolbox-icon.png'; + +export const AGENTIC_REACT_SHORTCUT_KEYS: AgenticReactShortcutKey[] = [ + 'singleSelect', + 'multiSelect', + 'toggleToolbox', + 'done', +]; + +export const PACKAGE_DEFAULT_SHORTCUTS: AgenticReactShortcutSettings = { + singleSelect: 'Ctrl+Alt+Shift+S', + multiSelect: 'Ctrl+Alt+Shift+M', + toggleToolbox: 'Ctrl+Alt+Shift+A', + done: 'Enter', +}; + +export const PACKAGE_DEFAULT_SETTINGS: AgenticReactSettings = { + schemaVersion: AGENTIC_REACT_SETTINGS_SCHEMA_VERSION, + shortcuts: PACKAGE_DEFAULT_SHORTCUTS, + appearance: { + toolboxIcon: null, + toolboxIconUrl: null, + }, +}; + +export interface PersistedAgenticReactSettings { + schemaVersion: 1; + shortcuts?: Partial; + appearance?: Partial; +} + +export interface PersistedSettingsParseResult { + settings: PersistedAgenticReactSettings | null; + errors: AgenticReactSettingsError[]; +} + +interface SanitizedProjectSettingsDefaults { + shortcuts?: Partial; + toolboxIconUrl?: string; +} + +const SHORTCUT_MAX_LENGTH = 80; +const TOOLBOX_ICON_URL_MAX_LENGTH = Math.ceil((1024 * 1024 * 4) / 3) + 128; + +const isObjectRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const createSettingsError = ( + code: AgenticReactSettingsError['code'], + message: string, + detail?: string, +): AgenticReactSettingsError => ({ + code, + message, + ...(detail ? { detail } : {}), +}); + +export const createUnavailableSettingsSnapshot = ( + message = 'Agentic React settings persistence is unavailable.', +): AgenticReactSettingsSnapshot => ({ + effectiveSettings: PACKAGE_DEFAULT_SETTINGS, + sources: { + shortcuts: { + singleSelect: 'package', + multiSelect: 'package', + toggleToolbox: 'package', + done: 'package', + }, + appearance: { + toolboxIcon: 'package', + }, + }, + errors: [createSettingsError('settings_unavailable', message)], +}); + +export const sanitizeProjectSettingsDefaults = ( + toolkitConfig: AgenticReactToolkitConfig | undefined, +): SanitizedProjectSettingsDefaults => { + const settingsDefaults = toolkitConfig?.settings; + const shortcuts = isObjectRecord(settingsDefaults) + ? sanitizeShortcutOverrides(settingsDefaults.shortcuts) + : {}; + const projectDefaults: SanitizedProjectSettingsDefaults = {}; + const iconUrl = sanitizeProjectIconUrl(toolkitConfig?.iconUrl); + + if (Object.keys(shortcuts).length > 0) { + projectDefaults.shortcuts = shortcuts; + } + if (iconUrl !== undefined) { + projectDefaults.toolboxIconUrl = iconUrl; + } + + return projectDefaults; +}; + +export const validatePersistedSettings = ( + value: unknown, +): PersistedSettingsParseResult => { + if (!isObjectRecord(value)) { + return { + settings: null, + errors: [ + createSettingsError( + 'invalid_settings', + 'Settings file must contain a JSON object.', + ), + ], + }; + } + + if (value.schemaVersion !== AGENTIC_REACT_SETTINGS_SCHEMA_VERSION) { + return { + settings: null, + errors: [ + createSettingsError( + 'unsupported_schema', + `Unsupported Agentic React settings schemaVersion: ${String( + value.schemaVersion, + )}.`, + ), + ], + }; + } + + const errors: AgenticReactSettingsError[] = []; + const shortcuts = sanitizeShortcutOverrides(value.shortcuts, errors); + const toolboxIcon = sanitizeToolboxIconMetadata(value.appearance, errors); + const settings: PersistedAgenticReactSettings = { + schemaVersion: AGENTIC_REACT_SETTINGS_SCHEMA_VERSION, + }; + + if (Object.keys(shortcuts).length > 0) { + settings.shortcuts = shortcuts; + } + if (toolboxIcon !== undefined) { + settings.appearance = { + toolboxIcon, + }; + } + + return { + settings, + errors, + }; +}; + +export const mergeSettings = ( + globalSettings: PersistedAgenticReactSettings | null, + projectDefaults: SanitizedProjectSettingsDefaults = {}, + errors: AgenticReactSettingsError[] = [], + globalToolboxIconUrl: string | null = null, +): AgenticReactSettingsSnapshot => { + const shortcuts = { ...PACKAGE_DEFAULT_SHORTCUTS }; + const shortcutSources: Record< + AgenticReactShortcutKey, + AgenticReactSettingsSource + > = { + singleSelect: 'package', + multiSelect: 'package', + toggleToolbox: 'package', + done: 'package', + }; + + for (const key of AGENTIC_REACT_SHORTCUT_KEYS) { + const projectValue = projectDefaults.shortcuts?.[key]; + if (isValidShortcut(projectValue)) { + shortcuts[key] = projectValue.trim(); + shortcutSources[key] = 'project'; + } + } + + for (const key of AGENTIC_REACT_SHORTCUT_KEYS) { + const globalValue = globalSettings?.shortcuts?.[key]; + if (isValidShortcut(globalValue)) { + shortcuts[key] = globalValue.trim(); + shortcutSources[key] = 'global'; + } + } + + const packageAppearance = PACKAGE_DEFAULT_SETTINGS.appearance; + const appearance: AgenticReactAppearanceSettings = { + toolboxIcon: null, + toolboxIconUrl: packageAppearance.toolboxIconUrl, + }; + const sources: AgenticReactSettingsSources = { + shortcuts: shortcutSources, + appearance: { + toolboxIcon: 'package', + }, + }; + + if ( + globalSettings?.appearance?.toolboxIcon !== undefined && + globalToolboxIconUrl + ) { + appearance.toolboxIcon = globalSettings.appearance.toolboxIcon; + appearance.toolboxIconUrl = globalToolboxIconUrl; + sources.appearance.toolboxIcon = 'global'; + } else if (projectDefaults.toolboxIconUrl) { + appearance.toolboxIconUrl = projectDefaults.toolboxIconUrl; + sources.appearance.toolboxIcon = 'project'; + } + + return { + effectiveSettings: { + schemaVersion: AGENTIC_REACT_SETTINGS_SCHEMA_VERSION, + shortcuts, + appearance, + }, + sources, + errors, + }; +}; + +export const normalizePersistedSettingsForWrite = ( + settings: PersistedAgenticReactSettings, +): PersistedAgenticReactSettings => { + const shortcuts = sanitizeShortcutOverrides(settings.shortcuts); + const normalized: PersistedAgenticReactSettings = { + schemaVersion: AGENTIC_REACT_SETTINGS_SCHEMA_VERSION, + }; + + if (Object.keys(shortcuts).length > 0) { + normalized.shortcuts = shortcuts; + } + if (settings.appearance?.toolboxIcon !== undefined) { + normalized.appearance = { + toolboxIcon: settings.appearance.toolboxIcon, + }; + } + + return normalized; +}; + +export const getIconFilenameForMime = ( + mime: AgenticReactToolboxIconMime, +): AgenticReactToolboxIconFilename => + mime === 'image/webp' + ? AGENTIC_REACT_WEBP_ICON_FILENAME + : AGENTIC_REACT_PNG_ICON_FILENAME; + +export const isAllowedToolboxIconMime = ( + mime: unknown, +): mime is AgenticReactToolboxIconMime => + mime === 'image/webp' || mime === 'image/png'; + +const sanitizeShortcutOverrides = ( + value: unknown, + errors: AgenticReactSettingsError[] = [], +): Partial => { + const shortcuts: Partial = {}; + if (value === undefined || value === null) { + return shortcuts; + } + + if (!isObjectRecord(value)) { + errors.push( + createSettingsError( + 'invalid_settings', + 'Settings shortcuts must be an object.', + ), + ); + return shortcuts; + } + + for (const key of AGENTIC_REACT_SHORTCUT_KEYS) { + const shortcut = value[key]; + if (shortcut === undefined) { + continue; + } + if (!isValidShortcut(shortcut)) { + errors.push( + createSettingsError( + 'invalid_settings', + `Invalid shortcut value for ${key}.`, + ), + ); + continue; + } + shortcuts[key] = shortcut.trim(); + } + + return shortcuts; +}; + +const sanitizeToolboxIconMetadata = ( + appearance: unknown, + errors: AgenticReactSettingsError[], +): AgenticReactToolboxIconMetadata | null | undefined => { + if (appearance === undefined || appearance === null) { + return undefined; + } + if (!isObjectRecord(appearance)) { + errors.push( + createSettingsError( + 'invalid_settings', + 'Settings appearance must be an object.', + ), + ); + return undefined; + } + if (!Object.hasOwn(appearance, 'toolboxIcon')) { + return undefined; + } + const toolboxIcon = appearance.toolboxIcon; + if (toolboxIcon === null) { + return null; + } + if (!isObjectRecord(toolboxIcon)) { + errors.push( + createSettingsError( + 'invalid_settings', + 'Toolbox icon metadata must be an object or null.', + ), + ); + return undefined; + } + if ( + (toolboxIcon.filename !== AGENTIC_REACT_WEBP_ICON_FILENAME && + toolboxIcon.filename !== AGENTIC_REACT_PNG_ICON_FILENAME) || + !isAllowedToolboxIconMime(toolboxIcon.mime) || + toolboxIcon.filename !== getIconFilenameForMime(toolboxIcon.mime) || + typeof toolboxIcon.updatedAt !== 'number' || + !Number.isFinite(toolboxIcon.updatedAt) || + toolboxIcon.updatedAt <= 0 + ) { + errors.push( + createSettingsError( + 'invalid_settings', + 'Toolbox icon metadata is invalid.', + ), + ); + return undefined; + } + + return { + filename: toolboxIcon.filename, + mime: toolboxIcon.mime, + updatedAt: toolboxIcon.updatedAt, + }; +}; + +const isValidShortcut = (value: unknown): value is string => + typeof value === 'string' && + value.trim().length > 0 && + value.trim().length <= SHORTCUT_MAX_LENGTH; + +const sanitizeProjectIconUrl = (value: unknown): string | null | undefined => { + if (value === null) { + return null; + } + if (typeof value !== 'string') { + return undefined; + } + + const trimmedValue = value.trim(); + if ( + trimmedValue.length === 0 || + trimmedValue.length > TOOLBOX_ICON_URL_MAX_LENGTH || + exposesLocalFilePath(trimmedValue) + ) { + return undefined; + } + + return trimmedValue; +}; + +const exposesLocalFilePath = (value: string): boolean => { + const lowerValue = value.toLowerCase(); + return ( + lowerValue.startsWith('file:') || + lowerValue.startsWith('~/') || + lowerValue.startsWith('/users/') || + lowerValue.startsWith('/home/') || + /^[a-z]:[\\/]/i.test(value) + ); +}; diff --git a/packages/core/src/core/settings/shortcuts.ts b/packages/core/src/core/settings/shortcuts.ts new file mode 100644 index 0000000..6673b76 --- /dev/null +++ b/packages/core/src/core/settings/shortcuts.ts @@ -0,0 +1,419 @@ +import type { + AgenticReactShortcutKey, + AgenticReactShortcutSettings, +} from '../../shared/types.js'; + +export const CONFIGURABLE_SHORTCUT_KEYS: AgenticReactShortcutKey[] = [ + 'singleSelect', + 'multiSelect', + 'toggleToolbox', + 'done', +]; + +export const LOCKED_ESCAPE_SHORTCUT = { + label: 'Escape', + description: 'Cancel selection', +}; + +export type ShortcutAction = AgenticReactShortcutKey; + +export interface ShortcutNormalizationSuccess { + success: true; + label: string; + identity: string; +} + +export interface ShortcutNormalizationFailure { + success: false; + reason: string; +} + +export type ShortcutNormalizationResult = + | ShortcutNormalizationSuccess + | ShortcutNormalizationFailure; + +export type ShortcutSettingsValidationResult = + | { + success: true; + shortcuts: AgenticReactShortcutSettings; + } + | { + success: false; + reason: string; + action: AgenticReactShortcutKey; + duplicateAction?: AgenticReactShortcutKey; + }; + +export interface ShortcutDispatcherOptions { + getShortcuts: () => AgenticReactShortcutSettings; + isActionApplicable: (action: ShortcutAction, event: KeyboardEvent) => boolean; + onAction: (action: ShortcutAction, event: KeyboardEvent) => void; + isPaused?: () => boolean; + platform?: string; +} + +export interface EscapeKeyCycleGuard { + handleKeyDown: ( + key: string, + ownsEscape: boolean, + ) => 'ignore' | 'initial' | 'repeat'; + handleKeyUp: (key: string) => boolean; +} + +/** + * Keeps Escape isolated for the entire physical key cycle. Selection state is + * normally cleared on the first keydown, but browsers may emit repeated + * keydowns before keyup; those repeats still belong to the toolbox. + */ +export const createEscapeKeyCycleGuard = (): EscapeKeyCycleGuard => { + let isAwaitingKeyUp = false; + + return { + handleKeyDown: (key, ownsEscape) => { + if (key !== 'Escape' || (!ownsEscape && !isAwaitingKeyUp)) { + return 'ignore'; + } + const disposition = isAwaitingKeyUp ? 'repeat' : 'initial'; + isAwaitingKeyUp = true; + return disposition; + }, + handleKeyUp: (key) => { + if (key !== 'Escape' || !isAwaitingKeyUp) { + return false; + } + isAwaitingKeyUp = false; + return true; + }, + }; +}; + +const MODIFIER_ORDER = ['Ctrl', 'Alt', 'Shift', 'Meta'] as const; +const MODIFIER_IDENTITY_ORDER = ['ctrl', 'alt', 'shift', 'meta'] as const; +const MODIFIER_ALIASES: Record = { + control: 'Ctrl', + ctrl: 'Ctrl', + option: 'Alt', + alt: 'Alt', + shift: 'Shift', + command: 'Meta', + cmd: 'Meta', + meta: 'Meta', + win: 'Meta', + windows: 'Meta', +}; +const PURE_MODIFIER_KEYS = new Set([ + 'Control', + 'Ctrl', + 'Alt', + 'Option', + 'Shift', + 'Meta', + 'Command', + 'OS', +]); +const NAMED_KEYS: Record = { + ' ': 'Space', + Spacebar: 'Space', + Esc: 'Escape', + Del: 'Delete', + Up: 'ArrowUp', + Down: 'ArrowDown', + Left: 'ArrowLeft', + Right: 'ArrowRight', +}; +const SUPPORTED_SPECIAL_KEYS = new Set([ + 'Enter', + 'Tab', + 'Backspace', + 'Delete', + 'Space', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown', +]); + +export const getShortcutActionLabel = ( + action: AgenticReactShortcutKey, +): string => { + switch (action) { + case 'singleSelect': + return 'Single select'; + case 'multiSelect': + return 'Multi select'; + case 'toggleToolbox': + return 'Toggle toolbox'; + case 'done': + return 'Done'; + } +}; + +export const isMacPlatform = (platform = getRuntimePlatform()): boolean => + /mac|iphone|ipad|ipod/i.test(platform); + +export const getRuntimePlatform = (): string => { + const navigatorLike = + typeof navigator !== 'undefined' + ? (navigator as Navigator & { + userAgentData?: { platform?: string }; + }) + : null; + const navigatorValue = + navigatorLike?.userAgentData?.platform || navigatorLike?.platform || ''; + return navigatorValue || ''; +}; + +export const normalizeShortcutString = ( + shortcut: string, + platform = getRuntimePlatform(), +): ShortcutNormalizationResult => { + const parts = shortcut + .split('+') + .map((part) => part.trim()) + .filter(Boolean); + const modifiers = new Set<(typeof MODIFIER_ORDER)[number]>(); + let key: string | null = null; + + for (const part of parts) { + const modifier = MODIFIER_ALIASES[part.toLowerCase()]; + if (modifier) { + modifiers.add(modifier); + continue; + } + + if (key) { + return { + success: false, + reason: 'Use one non-modifier key per shortcut.', + }; + } + key = normalizeKeyName(part); + } + + if (!key || PURE_MODIFIER_KEYS.has(key)) { + return { + success: false, + reason: 'Choose a key in addition to any modifiers.', + }; + } + if (key === 'Escape') { + return { + success: false, + reason: 'Escape is reserved for cancel selection.', + }; + } + if (!isSupportedShortcutKey(key)) { + return { + success: false, + reason: `Unsupported key: ${key}.`, + }; + } + + return buildShortcutResult(modifiers, key, platform); +}; + +export const normalizeShortcutFromEvent = ( + event: KeyboardEvent, + platform = getRuntimePlatform(), +): ShortcutNormalizationResult => { + const key = normalizeKeyName(event.key); + const modifiers = new Set<(typeof MODIFIER_ORDER)[number]>(); + if (event.ctrlKey && key !== 'Control') modifiers.add('Ctrl'); + if (event.altKey && key !== 'Alt') modifiers.add('Alt'); + if (event.shiftKey && key !== 'Shift') modifiers.add('Shift'); + if (event.metaKey && key !== 'Meta') modifiers.add('Meta'); + + if (!key || PURE_MODIFIER_KEYS.has(key)) { + return { + success: false, + reason: 'Choose a key in addition to any modifiers.', + }; + } + if (key === 'Escape') { + return { + success: false, + reason: 'Escape is reserved for cancel selection.', + }; + } + if (!isSupportedShortcutKey(key)) { + return { + success: false, + reason: `Unsupported key: ${key}.`, + }; + } + + return buildShortcutResult(modifiers, key, platform); +}; + +export const findDuplicateShortcut = ( + shortcut: string, + shortcuts: AgenticReactShortcutSettings, + exceptAction?: AgenticReactShortcutKey, + platform = getRuntimePlatform(), +): AgenticReactShortcutKey | null => { + const normalized = normalizeShortcutString(shortcut, platform); + if (!normalized.success) { + return null; + } + + for (const action of CONFIGURABLE_SHORTCUT_KEYS) { + if (action === exceptAction) { + continue; + } + const candidate = normalizeShortcutString(shortcuts[action], platform); + if (candidate.success && candidate.identity === normalized.identity) { + return action; + } + } + + return null; +}; + +/** Validates and canonicalizes a complete shortcut set. */ +export const validateShortcutSettings = ( + shortcuts: AgenticReactShortcutSettings, + platform = getRuntimePlatform(), +): ShortcutSettingsValidationResult => { + const normalized = {} as AgenticReactShortcutSettings; + const identities = new Map(); + + for (const action of CONFIGURABLE_SHORTCUT_KEYS) { + const result = normalizeShortcutString(shortcuts[action], platform); + if (result.success === false) { + return { + success: false, + reason: `${getShortcutActionLabel(action)}: ${result.reason}`, + action, + }; + } + + const duplicateAction = identities.get(result.identity); + if (duplicateAction) { + return { + success: false, + reason: `${result.label} is already assigned to ${getShortcutActionLabel( + duplicateAction, + )}.`, + action, + duplicateAction, + }; + } + + identities.set(result.identity, action); + normalized[action] = result.label; + } + + return { success: true, shortcuts: normalized }; +}; + +export const createShortcutDispatcher = ({ + getShortcuts, + isActionApplicable, + onAction, + isPaused = () => false, + platform = getRuntimePlatform(), +}: ShortcutDispatcherOptions) => { + const handleKeyDown = (event: KeyboardEvent): boolean => { + if (event.defaultPrevented || isPaused()) { + return false; + } + + const pressed = normalizeShortcutFromEvent(event, platform); + if (!pressed.success) { + return false; + } + + const shortcuts = getShortcuts(); + for (const action of CONFIGURABLE_SHORTCUT_KEYS) { + const configured = normalizeShortcutString(shortcuts[action], platform); + if ( + configured.success && + configured.identity === pressed.identity && + isActionApplicable(action, event) + ) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + onAction(action, event); + return true; + } + } + + return false; + }; + + return { + handleKeyDown, + }; +}; + +const buildShortcutResult = ( + modifiers: Set<(typeof MODIFIER_ORDER)[number]>, + key: string, + platform: string, +): ShortcutNormalizationSuccess => { + const orderedModifiers = MODIFIER_ORDER.filter((modifier) => + modifiers.has(modifier), + ); + const labelParts = [ + ...orderedModifiers.map((modifier) => displayModifier(modifier, platform)), + key, + ]; + const identityParts = [ + ...MODIFIER_IDENTITY_ORDER.filter((modifier) => + modifiers.has(identityToModifier(modifier)), + ), + key.toLowerCase(), + ]; + + return { + success: true, + label: labelParts.join('+'), + identity: identityParts.join('+'), + }; +}; + +const identityToModifier = ( + modifier: (typeof MODIFIER_IDENTITY_ORDER)[number], +): (typeof MODIFIER_ORDER)[number] => { + switch (modifier) { + case 'ctrl': + return 'Ctrl'; + case 'alt': + return 'Alt'; + case 'shift': + return 'Shift'; + case 'meta': + return 'Meta'; + } +}; + +const displayModifier = ( + modifier: (typeof MODIFIER_ORDER)[number], + platform: string, +): string => { + if (!isMacPlatform(platform)) { + return modifier; + } + + if (modifier === 'Meta') return 'Command'; + if (modifier === 'Alt') return 'Option'; + return modifier; +}; + +const normalizeKeyName = (key: string): string => { + const mapped = NAMED_KEYS[key] || key; + if (/^[a-z]$/i.test(mapped)) { + return mapped.toUpperCase(); + } + return mapped; +}; + +const isSupportedShortcutKey = (key: string): boolean => + /^[A-Z0-9]$/.test(key) || + /^F(?:[1-9]|1[0-9]|2[0-4])$/.test(key) || + SUPPORTED_SPECIAL_KEYS.has(key); diff --git a/packages/core/src/core/settings/ui.ts b/packages/core/src/core/settings/ui.ts new file mode 100644 index 0000000..eb6e206 --- /dev/null +++ b/packages/core/src/core/settings/ui.ts @@ -0,0 +1,117 @@ +import type { AgenticReactSettingsSource } from '../../shared/types.js'; + +export const SETTINGS_SOURCE_LABELS: Record< + AgenticReactSettingsSource, + string +> = { + global: 'Global override', + project: 'Project configuration', + package: 'Default', +}; + +export const stopHostActivationEvents = (element: HTMLElement) => { + const stopEvent = (event: Event) => { + event.stopPropagation(); + }; + for (const eventName of [ + 'pointerdown', + 'pointerup', + 'mousedown', + 'mouseup', + 'click', + 'touchstart', + 'touchend', + 'contextmenu', + ]) { + // Isolate at the toolbox boundary after descendant handlers have run. A + // capture listener here would prevent the toolbox controls themselves from + // receiving activation events. + element.addEventListener(eventName, stopEvent); + } +}; + +export const createSettingsSectionTitle = (text: string): HTMLDivElement => { + const element = document.createElement('div'); + element.textContent = text; + element.style.fontSize = '11px'; + element.style.fontWeight = '800'; + element.style.letterSpacing = '0'; + element.style.color = '#334155'; + element.style.paddingTop = '8px'; + element.style.borderTop = '1px solid rgba(15, 23, 42, 0.08)'; + return element; +}; + +export const createSourceBadge = ( + source: AgenticReactSettingsSource, +): HTMLSpanElement => { + const element = document.createElement('span'); + element.textContent = SETTINGS_SOURCE_LABELS[source]; + element.style.display = 'inline-flex'; + element.style.alignItems = 'center'; + element.style.minHeight = '20px'; + element.style.padding = '2px 6px'; + element.style.border = '1px solid rgba(15, 23, 42, 0.12)'; + element.style.borderRadius = '999px'; + element.style.background = source === 'global' ? '#ecfeff' : '#f8fafc'; + element.style.color = source === 'global' ? '#155e75' : '#475569'; + element.style.fontSize = '10px'; + element.style.fontWeight = '800'; + element.style.whiteSpace = 'nowrap'; + return element; +}; + +export const createSmallButton = ( + label: string, + options: { danger?: boolean; muted?: boolean } = {}, +): HTMLButtonElement => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.setAttribute('aria-label', label); + button.style.height = '30px'; + button.style.padding = '6px 8px'; + button.style.border = '1px solid rgba(15, 23, 42, 0.14)'; + button.style.borderRadius = '8px'; + button.style.background = options.danger + ? '#fee2e2' + : options.muted + ? '#f8fafc' + : '#111827'; + button.style.color = options.danger + ? '#991b1b' + : options.muted + ? '#111827' + : '#ffffff'; + button.style.fontSize = '11px'; + button.style.fontWeight = '800'; + button.style.cursor = 'pointer'; + return button; +}; + +export const createKeycap = (text: string): HTMLSpanElement => { + const element = document.createElement('span'); + element.textContent = text; + element.style.display = 'inline-flex'; + element.style.alignItems = 'center'; + element.style.minHeight = '24px'; + element.style.maxWidth = '100%'; + element.style.padding = '3px 7px'; + element.style.border = '1px solid rgba(15, 23, 42, 0.18)'; + element.style.borderRadius = '6px'; + element.style.background = '#ffffff'; + element.style.color = '#111827'; + element.style.fontFamily = 'ui-monospace, SFMono-Regular, monospace'; + element.style.fontSize = '11px'; + element.style.fontWeight = '800'; + element.style.overflow = 'hidden'; + element.style.textOverflow = 'ellipsis'; + element.style.whiteSpace = 'nowrap'; + return element; +}; + +export const setDisabled = (element: HTMLButtonElement, disabled: boolean) => { + element.disabled = disabled; + element.style.opacity = disabled ? '0.48' : '1'; + element.style.cursor = disabled ? 'not-allowed' : 'pointer'; +}; diff --git a/packages/core/src/core/tools/selection_toolkit.ts b/packages/core/src/core/tools/selection_toolkit.ts index 498e023..e65f17a 100644 --- a/packages/core/src/core/tools/selection_toolkit.ts +++ b/packages/core/src/core/tools/selection_toolkit.ts @@ -12,6 +12,9 @@ import { toAbsoluteSourcePath, } from '../../shared/source_path.js'; import type { + AgenticReactSettingsClient, + AgenticReactSettingsSnapshot, + AgenticReactShortcutKey, SelectionContext, SelectionResolvedSource, ToolkitConfig, @@ -23,6 +26,25 @@ import type { TuningModalExtension, TuningModalExtensionCleanup, } from '../../shared/types.js'; +import { openToolboxIconCropper } from '../settings/icon_modal.js'; +import { + CONFIGURABLE_SHORTCUT_KEYS, + LOCKED_ESCAPE_SHORTCUT, + createEscapeKeyCycleGuard, + createShortcutDispatcher, + findDuplicateShortcut, + getShortcutActionLabel, + normalizeShortcutFromEvent, + normalizeShortcutString, +} from '../settings/shortcuts.js'; +import { + createKeycap, + createSettingsSectionTitle, + createSmallButton, + createSourceBadge, + setDisabled, + stopHostActivationEvents, +} from '../settings/ui.js'; import { buildSelectionContextForElement, getSelectionElementComponentName, @@ -34,6 +56,11 @@ import { interface ToolkitRuntimeOptions { initialConfig?: ToolkitConfig; + settingsClient?: AgenticReactSettingsClient; + sourceLookup?: ( + componentName: string, + selector: string, + ) => Promise; } interface ToolkitRuntimeResult { @@ -665,6 +692,15 @@ const copyText = async (text: string): Promise => { } }; +const preloadImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(); + image.onerror = () => reject(new Error('Image could not be loaded.')); + image.decoding = 'async'; + image.src = url; + }); + const createOverlayIconButton = ( label: string, iconPathMarkup: string, @@ -801,21 +837,33 @@ const placeResolvedSourceFirst = ( const enrichSelectionContextSourceLocation = async ( selectionContext: SelectionContext, + sourceLookup?: ToolkitRuntimeOptions['sourceLookup'], ): 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; + let source: SelectionResolvedSource | null; + if (sourceLookup) { + source = await sourceLookup( + selectionContext.componentName, + selectionContext.selector, + ); + } else { + 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; + } + source = (await response.json()) as SelectionResolvedSource; } - const source = (await response.json()) as SelectionResolvedSource; + if (!source) { + return selectionContext; + } if (!source.filePath || !source.componentName) { return selectionContext; } @@ -875,13 +923,22 @@ const buildSourceSnippet = ( export const createSelectionToolkit = ( options: ToolkitRuntimeOptions = {}, ): ToolkitRuntimeResult => { + const settingsClient = options.settingsClient; + const sourceLookup = options.sourceLookup; + let settingsSnapshot: AgenticReactSettingsSnapshot | null = + settingsClient?.getCachedSnapshot() || null; let toolkitConfig = mergeToolkitConfig(options.initialConfig, undefined); let isToolkitVisible = toolkitConfig.defaultVisible && toolkitConfig.enabled; let isPanelOpen = toolkitConfig.defaultExpanded; + let isSettingsSectionOpen = false; let isSelectionMode = false; let isMultiSelectionMode = false; + let pendingSingleSelectionContext: SelectionContext | null = null; let lastSelectionContext: SelectionContext | null = null; let multiSelectionContexts: SelectionContext[] = []; + let recordingShortcutAction: AgenticReactShortcutKey | null = null; + let selectionSessionId = 0; + const escapeKeyCycleGuard = createEscapeKeyCycleGuard(); const toolkitRootElement = document.createElement('div'); const launcherButtonElement = document.createElement('button'); @@ -891,6 +948,14 @@ export const createSelectionToolkit = ( const selectButtonElement = document.createElement('button'); const multiselectButtonElement = document.createElement('button'); const doneButtonElement = document.createElement('button'); + const settingsButtonElement = document.createElement('button'); + const settingsSectionElement = document.createElement('div'); + const shortcutsSectionElement = document.createElement('div'); + const appearanceSectionElement = document.createElement('div'); + const iconPreviewElement = document.createElement('img'); + const iconFileInputElement = document.createElement('input'); + const iconChangeButtonElement = createSmallButton('Change', { muted: true }); + const iconResetButtonElement = createSmallButton('Reset', { muted: true }); const clearAllButtonElement = document.createElement('button'); const clearAllIconElement = document.createElementNS( 'http://www.w3.org/2000/svg', @@ -1039,11 +1104,41 @@ export const createSelectionToolkit = ( createButton(selectButtonElement, 'Select'); createButton(multiselectButtonElement, 'Multiselect'); createButton(doneButtonElement, 'Done'); + createButton(settingsButtonElement, 'Settings'); createTrashIconButton(); doneButtonElement.style.background = '#dc2626'; doneButtonElement.style.color = '#ffffff'; doneButtonElement.style.display = 'none'; + settingsButtonElement.setAttribute('aria-expanded', 'false'); + settingsButtonElement.setAttribute('aria-controls', 'agentic-react-settings'); + settingsButtonElement.style.background = '#334155'; + settingsButtonElement.style.color = '#ffffff'; + + settingsSectionElement.id = 'agentic-react-settings'; + settingsSectionElement.setAttribute('data-agentic-react-settings', 'true'); + settingsSectionElement.style.display = 'none'; + settingsSectionElement.style.flexDirection = 'column'; + settingsSectionElement.style.gap = '10px'; + settingsSectionElement.style.paddingTop = '2px'; + + shortcutsSectionElement.style.display = 'flex'; + shortcutsSectionElement.style.flexDirection = 'column'; + shortcutsSectionElement.style.gap = '8px'; + appearanceSectionElement.style.display = 'flex'; + appearanceSectionElement.style.flexDirection = 'column'; + appearanceSectionElement.style.gap = '8px'; + + iconPreviewElement.alt = 'Current toolbox icon'; + iconPreviewElement.style.width = `${LAUNCHER_SIZE}px`; + iconPreviewElement.style.height = `${LAUNCHER_SIZE}px`; + iconPreviewElement.style.borderRadius = '999px'; + iconPreviewElement.style.objectFit = 'cover'; + iconPreviewElement.style.boxShadow = '0 0 0 1px rgba(15, 23, 42, 0.16)'; + + iconFileInputElement.type = 'file'; + iconFileInputElement.accept = 'image/png,image/jpeg,image/webp,image/gif'; + iconFileInputElement.style.display = 'none'; statusElement.style.fontSize = '11px'; statusElement.style.color = '#111827'; @@ -1058,6 +1153,11 @@ export const createSelectionToolkit = ( panelElement.appendChild(multiselectButtonElement); panelElement.appendChild(doneButtonElement); panelElement.appendChild(clearAllButtonElement); + panelElement.appendChild(settingsButtonElement); + settingsSectionElement.appendChild(shortcutsSectionElement); + settingsSectionElement.appendChild(appearanceSectionElement); + panelElement.appendChild(settingsSectionElement); + panelElement.appendChild(iconFileInputElement); panelElement.appendChild(statusElement); toolkitRootElement.appendChild(panelElement); @@ -1177,6 +1277,9 @@ export const createSelectionToolkit = ( tuningModalElement.appendChild(tuningArrowElement); tuningSurfaceElement.appendChild(tuningPanelElement); tuningModalElement.appendChild(tuningSurfaceElement); + stopHostActivationEvents(toolkitRootElement); + stopHostActivationEvents(tuningModalElement); + stopHostActivationEvents(selectedActions.actionsElement); const getTuningModalConfig = () => toolkitConfig.tuningModal || {}; @@ -1425,14 +1528,59 @@ export const createSelectionToolkit = ( (document.head || document.documentElement).appendChild(styleElement); }; + let renderSettingsPanel = () => {}; + const updateStatus = (message: string, shouldDisplay = true) => { statusElement.textContent = message; statusElement.style.display = shouldDisplay ? 'block' : 'none'; }; + const getEffectiveShortcutSettings = () => + settingsSnapshot?.effectiveSettings.shortcuts; + + const getEffectiveIconUrl = () => + settingsSnapshot?.effectiveSettings.appearance.toolboxIconUrl || + toolkitConfig.iconUrl || + DEFAULT_TOOLKIT_ICON_DATA_URL; + + const getSettingsCapabilityMessage = () => { + const capability = settingsClient?.getCapability(); + if (capability?.available) { + return ''; + } + return ( + capability?.reason || + 'Settings persistence is unavailable in this runtime. Changes cannot be saved.' + ); + }; + + const applySettingsSnapshot = async ( + result: Awaited< + ReturnType + >, + ) => { + settingsSnapshot = { + effectiveSettings: result.effectiveSettings, + sources: result.sources, + errors: result.errors, + }; + await renderLauncherLogo(); + renderSettingsPanel(); + renderPanelVisibility(); + }; + const renderMultiSelectionControls = () => { const hasSelections = multiSelectionContexts.length > 0; - doneButtonElement.style.display = isMultiSelectionMode ? 'block' : 'none'; + const hasPendingSingleSelection = + !!pendingSingleSelectionContext && !!selectedTargetElement; + doneButtonElement.style.display = + isMultiSelectionMode || hasPendingSingleSelection ? 'block' : 'none'; + doneButtonElement.disabled = + isMultiSelectionMode && !hasSelections && !hasPendingSingleSelection; + doneButtonElement.style.opacity = doneButtonElement.disabled ? '0.45' : '1'; + doneButtonElement.style.cursor = doneButtonElement.disabled + ? 'not-allowed' + : 'pointer'; clearAllButtonElement.style.display = isMultiSelectionMode ? 'flex' : 'none'; @@ -1448,12 +1596,28 @@ export const createSelectionToolkit = ( const renderPanelVisibility = () => { renderMultiSelectionControls(); + settingsButtonElement.setAttribute( + 'aria-expanded', + String(isSettingsSectionOpen), + ); + settingsSectionElement.style.display = isSettingsSectionOpen + ? 'flex' + : 'none'; panelElement.style.display = isPanelOpen ? 'flex' : 'none'; }; - const renderLauncherLogo = () => { - launcherIconElement.src = - (toolkitConfig.iconUrl || '').trim() || DEFAULT_TOOLKIT_ICON_DATA_URL; + const renderLauncherLogo = async () => { + const nextIconUrl = getEffectiveIconUrl(); + try { + if (nextIconUrl !== DEFAULT_TOOLKIT_ICON_DATA_URL) { + await preloadImage(nextIconUrl); + } + launcherIconElement.src = nextIconUrl; + iconPreviewElement.src = nextIconUrl; + } catch (_error) { + launcherIconElement.src = DEFAULT_TOOLKIT_ICON_DATA_URL; + iconPreviewElement.src = DEFAULT_TOOLKIT_ICON_DATA_URL; + } }; const updateToolkitStyle = () => { @@ -1516,7 +1680,8 @@ export const createSelectionToolkit = ( dimElement.style.zIndex = String(Math.max(0, toolkitConfig.zIndex - 3)); } applyTuningModalStaticStyles(); - renderLauncherLogo(); + renderSettingsPanel(); + void renderLauncherLogo(); renderPanelVisibility(); }; @@ -1527,6 +1692,177 @@ export const createSelectionToolkit = ( overlayElement.style.height = `${rect.height}px`; }; + const createSettingsHelpText = (text: string) => { + const element = document.createElement('div'); + element.textContent = text; + element.style.fontSize = '11px'; + element.style.lineHeight = '1.35'; + element.style.color = '#64748b'; + return element; + }; + + const renderShortcutRows = () => { + shortcutsSectionElement.replaceChildren( + createSettingsSectionTitle('Shortcuts'), + ); + + const shortcuts = getEffectiveShortcutSettings(); + const sources = settingsSnapshot?.sources.shortcuts; + if (!shortcuts || !sources) { + shortcutsSectionElement.appendChild( + createSettingsHelpText(getSettingsCapabilityMessage()), + ); + return; + } + + for (const action of CONFIGURABLE_SHORTCUT_KEYS) { + const rowElement = document.createElement('div'); + const textWrapElement = document.createElement('div'); + const labelElement = document.createElement('div'); + const controlsElement = document.createElement('div'); + const normalized = normalizeShortcutString(shortcuts[action]); + const keyButtonElement = createSmallButton( + recordingShortcutAction === action + ? 'Press shortcut' + : normalized.success + ? normalized.label + : shortcuts[action], + { muted: true }, + ); + const resetButtonElement = createSmallButton('Reset', { muted: true }); + + rowElement.style.display = 'grid'; + rowElement.style.gridTemplateColumns = 'minmax(0, 1fr) auto'; + rowElement.style.gap = '8px'; + rowElement.style.alignItems = 'center'; + + labelElement.textContent = getShortcutActionLabel(action); + labelElement.style.fontSize = '12px'; + labelElement.style.fontWeight = '800'; + labelElement.style.color = '#111827'; + textWrapElement.style.display = 'flex'; + textWrapElement.style.flexDirection = 'column'; + textWrapElement.style.gap = '4px'; + textWrapElement.appendChild(labelElement); + textWrapElement.appendChild(createSourceBadge(sources[action])); + + controlsElement.style.display = 'grid'; + controlsElement.style.gridTemplateColumns = 'minmax(78px, 1fr) auto'; + controlsElement.style.gap = '6px'; + controlsElement.appendChild(keyButtonElement); + controlsElement.appendChild(resetButtonElement); + + keyButtonElement.addEventListener('click', () => { + if (!settingsClient?.getCapability().available) { + updateStatus(getSettingsCapabilityMessage()); + return; + } + recordingShortcutAction = action; + renderSettingsPanel(); + updateStatus(`Recording ${getShortcutActionLabel(action)} shortcut.`); + }); + + resetButtonElement.addEventListener('click', () => { + if (!settingsClient?.getCapability().available) { + updateStatus(getSettingsCapabilityMessage()); + return; + } + setDisabled(resetButtonElement, true); + void settingsClient + .resetShortcut(action) + .then((result) => { + if (result.success === false) { + updateStatus(result.error.message); + renderSettingsPanel(); + return; + } + return applySettingsSnapshot(result).then(() => { + updateStatus(`Reset ${getShortcutActionLabel(action)} shortcut.`); + }); + }) + .catch((error) => { + updateStatus( + error instanceof Error + ? error.message + : 'Failed to reset shortcut.', + ); + renderSettingsPanel(); + }); + }); + + rowElement.appendChild(textWrapElement); + rowElement.appendChild(controlsElement); + shortcutsSectionElement.appendChild(rowElement); + } + + const escapeRowElement = document.createElement('div'); + const escapeLabelElement = document.createElement('div'); + escapeRowElement.style.display = 'grid'; + escapeRowElement.style.gridTemplateColumns = 'minmax(0, 1fr) auto'; + escapeRowElement.style.gap = '8px'; + escapeRowElement.style.alignItems = 'center'; + escapeLabelElement.textContent = LOCKED_ESCAPE_SHORTCUT.description; + escapeLabelElement.style.fontSize = '12px'; + escapeLabelElement.style.fontWeight = '800'; + escapeLabelElement.style.color = '#111827'; + escapeRowElement.appendChild(escapeLabelElement); + escapeRowElement.appendChild(createKeycap(LOCKED_ESCAPE_SHORTCUT.label)); + shortcutsSectionElement.appendChild(escapeRowElement); + }; + + const renderAppearanceSettings = () => { + appearanceSectionElement.replaceChildren( + createSettingsSectionTitle('Appearance'), + ); + + const source = + settingsSnapshot?.sources.appearance.toolboxIcon || 'package'; + const controlsElement = document.createElement('div'); + const previewRowElement = document.createElement('div'); + const textWrapElement = document.createElement('div'); + const titleElement = document.createElement('div'); + const capabilityMessage = getSettingsCapabilityMessage(); + + previewRowElement.style.display = 'grid'; + previewRowElement.style.gridTemplateColumns = `${LAUNCHER_SIZE}px minmax(0, 1fr)`; + previewRowElement.style.gap = '10px'; + previewRowElement.style.alignItems = 'center'; + + titleElement.textContent = 'Toolbox icon'; + titleElement.style.fontSize = '12px'; + titleElement.style.fontWeight = '800'; + titleElement.style.color = '#111827'; + textWrapElement.style.display = 'flex'; + textWrapElement.style.flexDirection = 'column'; + textWrapElement.style.gap = '5px'; + textWrapElement.appendChild(titleElement); + textWrapElement.appendChild(createSourceBadge(source)); + + controlsElement.style.display = 'flex'; + controlsElement.style.gap = '6px'; + controlsElement.style.flexWrap = 'wrap'; + controlsElement.appendChild(iconChangeButtonElement); + if (source === 'global') { + controlsElement.appendChild(iconResetButtonElement); + } + textWrapElement.appendChild(controlsElement); + + previewRowElement.appendChild(iconPreviewElement); + previewRowElement.appendChild(textWrapElement); + appearanceSectionElement.appendChild(previewRowElement); + + if (capabilityMessage) { + appearanceSectionElement.appendChild( + createSettingsHelpText(capabilityMessage), + ); + } + }; + + renderSettingsPanel = () => { + renderShortcutRows(); + renderAppearanceSettings(); + }; + const updateOverlayLabelForElement = ( labelElement: HTMLElement, element: Element, @@ -2508,7 +2844,10 @@ export const createSelectionToolkit = ( selectionContext, ); - return enrichSelectionContextSourceLocation(selectionContext); + return enrichSelectionContextSourceLocation( + selectionContext, + sourceLookup, + ); }) .then((selectionContext) => { if (!selectionContext) { @@ -2580,6 +2919,7 @@ export const createSelectionToolkit = ( overlayElement.appendChild(labelElement); overlayElement.appendChild(actions.actionsElement); + stopHostActivationEvents(actions.actionsElement); document.body.appendChild(overlayElement); const overlay = { @@ -2715,10 +3055,37 @@ export const createSelectionToolkit = ( renderMultiSelectionControls(); }; + const clearPendingSingleSelection = () => { + pendingSingleSelectionContext = null; + hideSelectedOverlay(); + }; + + const cancelPendingSelection = () => { + selectionSessionId += 1; + isSelectionMode = false; + isMultiSelectionMode = false; + clearPendingSingleSelection(); + clearMultiSelections(); + hideHoverOverlay(); + hideDimOverlay(); + closeTuningModal(); + clearSelectableElementCache(); + selectButtonElement.textContent = 'Select'; + multiselectButtonElement.textContent = 'Multiselect'; + launcherButtonElement.setAttribute( + 'aria-label', + 'Open Agentic React toolkit', + ); + isPanelOpen = true; + renderPanelVisibility(); + updateStatus('Selection cancelled'); + }; + const showSelectedForElement = ( element: Element, shouldPulse = false, - selectionContext: SelectionContext | null = lastSelectionContext, + selectionContext: SelectionContext | null = pendingSingleSelectionContext || + lastSelectionContext, ) => { if (toolkitRootElement.contains(element)) { hideSelectedOverlay(); @@ -2849,16 +3216,22 @@ export const createSelectionToolkit = ( mouseEvent.stopImmediatePropagation(); let didCaptureSelection = false; + const captureSessionId = selectionSessionId; try { const selectionContext = await enrichSelectionContextSourceLocation( await buildSelectionContextForElement(selectedTarget), + sourceLookup, ); - lastSelectionContext = selectionContext; + if (captureSessionId !== selectionSessionId || !isSelectionMode) { + return; + } if (isMultiSelectionMode) { + pendingSingleSelectionContext = null; hideSelectedOverlay(); showMultiSelectedForElement(selectedTarget, selectionContext, true); } else { + pendingSingleSelectionContext = selectionContext; showSelectedForElement(selectedTarget, true, selectionContext); } showDimForElement(selectedTarget); @@ -2883,51 +3256,54 @@ export const createSelectionToolkit = ( `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.'}`, - ); + updateStatus(`Captured ${capturedSelectionLabel}. Click Done to copy.`); } } 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', - ); + if (captureSessionId === selectionSessionId) { + updateStatus(message); } - hideHoverOverlay(); - if (!didCaptureSelection) { - hideDimOverlay(); + } finally { + // A prior async capture can finish after Escape and a new selection + // session have already started. It must not reset the new session's + // mode, controls, overlays, or selectable-element cache. + if (captureSessionId === selectionSessionId) { + 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'; + launcherButtonElement.setAttribute( + 'aria-label', + 'Open Agentic React toolkit', + ); + } + renderMultiSelectionControls(); + hideHoverOverlay(); + if (!didCaptureSelection) { + hideDimOverlay(); + } + clearSelectableElementCache(); } - clearSelectableElementCache(); } }; const enterSelectionMode = () => { + selectionSessionId += 1; isSelectionMode = true; isMultiSelectionMode = false; clearMultiSelections(); + clearPendingSingleSelection(); clearSelectableElementCache(); - hideSelectedOverlay(); isPanelOpen = true; renderPanelVisibility(); selectButtonElement.textContent = 'Selecting...'; @@ -2943,11 +3319,12 @@ export const createSelectionToolkit = ( }; const enterMultiSelectionMode = () => { + selectionSessionId += 1; isSelectionMode = true; isMultiSelectionMode = true; clearMultiSelections(); + clearPendingSingleSelection(); clearSelectableElementCache(); - hideSelectedOverlay(); isPanelOpen = true; renderPanelVisibility(); selectButtonElement.textContent = 'Select'; @@ -2961,12 +3338,14 @@ export const createSelectionToolkit = ( }; const exitSelectionMode = () => { + selectionSessionId += 1; isSelectionMode = false; isMultiSelectionMode = false; + pendingSingleSelectionContext = null; clearMultiSelections(); + hideSelectedOverlay(); selectButtonElement.textContent = 'Select'; multiselectButtonElement.textContent = 'Multiselect'; - doneButtonElement.style.display = 'none'; launcherButtonElement.setAttribute( 'aria-label', 'Open Agentic React toolkit', @@ -2975,6 +3354,7 @@ export const createSelectionToolkit = ( hideHoverOverlay(); hideDimOverlay(); clearSelectableElementCache(); + renderMultiSelectionControls(); }; const togglePanel = () => { @@ -3018,8 +3398,10 @@ export const createSelectionToolkit = ( const enrichSelectionContextSourceSnippets = async ( selectionContext: SelectionContext, ): Promise => { - let enrichedSelectionContext = - await enrichSelectionContextSourceLocation(selectionContext); + let enrichedSelectionContext = await enrichSelectionContextSourceLocation( + selectionContext, + sourceLookup, + ); if (enrichedSelectionContext.sourceSnippets.length > 0) { return enrichedSelectionContext; @@ -3188,10 +3570,246 @@ export const createSelectionToolkit = ( }; }; + const hasPendingSelection = () => + !!pendingSingleSelectionContext || multiSelectionContexts.length > 0; + + const commitPendingSelection = () => { + doneButtonElement.click(); + }; + + const shortcutDispatcher = createShortcutDispatcher({ + getShortcuts: () => + settingsSnapshot?.effectiveSettings.shortcuts || + target[__AGENTIC_REACT_CONFIG__]?.settings?.effectiveSettings + .shortcuts || { + singleSelect: 'Ctrl+Alt+Shift+S', + multiSelect: 'Ctrl+Alt+Shift+M', + toggleToolbox: 'Ctrl+Alt+Shift+A', + done: 'Enter', + }, + isPaused: () => recordingShortcutAction !== null, + isActionApplicable: (action) => { + if (!toolkitConfig.enabled) { + return false; + } + if (action === 'done') { + return hasPendingSelection(); + } + return true; + }, + onAction: (action) => { + if (action === 'singleSelect') { + enterSelectionMode(); + } else if (action === 'multiSelect') { + enterMultiSelectionMode(); + } else if (action === 'toggleToolbox') { + togglePanel(); + } else { + commitPendingSelection(); + } + }, + }); + + const handleShortcutRecording = (event: KeyboardEvent): boolean => { + if (!recordingShortcutAction) { + return false; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + if (event.key === 'Escape') { + escapeKeyCycleGuard.handleKeyDown(event.key, true); + recordingShortcutAction = null; + renderSettingsPanel(); + updateStatus('Shortcut recording cancelled.'); + return true; + } + + const normalized = normalizeShortcutFromEvent(event); + if (normalized.success === false) { + updateStatus(normalized.reason); + renderSettingsPanel(); + return true; + } + + const existingShortcuts = getEffectiveShortcutSettings(); + if (!existingShortcuts || !settingsClient) { + updateStatus(getSettingsCapabilityMessage()); + recordingShortcutAction = null; + renderSettingsPanel(); + return true; + } + + const duplicateAction = findDuplicateShortcut( + normalized.label, + existingShortcuts, + recordingShortcutAction, + ); + if (duplicateAction) { + updateStatus( + `${normalized.label} is already assigned to ${getShortcutActionLabel( + duplicateAction, + )}.`, + ); + renderSettingsPanel(); + return true; + } + + const actionToSave = recordingShortcutAction; + recordingShortcutAction = null; + renderSettingsPanel(); + updateStatus(`Saving ${getShortcutActionLabel(actionToSave)} shortcut.`); + void settingsClient + .updateShortcuts({ [actionToSave]: normalized.label }) + .then((result) => { + if (result.success === false) { + updateStatus(result.error.message); + renderSettingsPanel(); + return; + } + return applySettingsSnapshot(result).then(() => { + updateStatus(`Saved ${normalized.label}.`); + }); + }) + .catch((error) => { + updateStatus( + error instanceof Error ? error.message : 'Failed to save shortcut.', + ); + renderSettingsPanel(); + }); + return true; + }; + + const handleEscapeKeyDown = (event: KeyboardEvent): boolean => { + const ownsEscape = + !!activeTuningSession || isSelectionMode || hasPendingSelection(); + const disposition = escapeKeyCycleGuard.handleKeyDown( + event.key, + ownsEscape, + ); + if (disposition === 'ignore') { + return false; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + if (disposition === 'repeat') { + return true; + } + + if (activeTuningSession) { + closeTuningModal(); + return true; + } + + if (isSelectionMode || hasPendingSelection()) { + cancelPendingSelection(); + } + return true; + }; + + const onGlobalKeyDown = (event: KeyboardEvent) => { + if (handleShortcutRecording(event)) { + return; + } + if (event.key === 'Escape' && handleEscapeKeyDown(event)) { + return; + } + shortcutDispatcher.handleKeyDown(event); + }; + + const onGlobalKeyUp = (event: KeyboardEvent) => { + if (!escapeKeyCycleGuard.handleKeyUp(event.key)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + + settingsButtonElement.addEventListener('click', () => { + isSettingsSectionOpen = !isSettingsSectionOpen; + if (isSettingsSectionOpen) { + renderSettingsPanel(); + } + renderPanelVisibility(); + }); + + iconChangeButtonElement.addEventListener('click', () => { + if (!settingsClient?.getCapability().available) { + updateStatus(getSettingsCapabilityMessage()); + return; + } + iconFileInputElement.click(); + }); + + iconFileInputElement.addEventListener('change', () => { + const file = iconFileInputElement.files?.[0]; + iconFileInputElement.value = ''; + if (!file) { + return; + } + void openToolboxIconCropper({ + file, + zIndex: toolkitConfig.zIndex + 2, + restoreFocusTo: iconChangeButtonElement, + onError: (message) => updateStatus(message), + onCancel: () => updateStatus('Icon change cancelled.'), + onApply: async (icon) => { + if (!settingsClient) { + throw new Error(getSettingsCapabilityMessage()); + } + const result = await settingsClient.applyIcon(icon); + if (result.success === false) { + throw new Error(result.error.message); + } + await preloadImage( + result.effectiveSettings.appearance.toolboxIconUrl || '', + ); + await applySettingsSnapshot(result); + updateStatus('Updated toolbox icon.'); + }, + }).catch((error) => { + updateStatus( + error instanceof Error ? error.message : 'Failed to process image.', + ); + }); + }); + + iconResetButtonElement.addEventListener('click', () => { + if (!settingsClient?.getCapability().available) { + updateStatus(getSettingsCapabilityMessage()); + return; + } + setDisabled(iconResetButtonElement, true); + void settingsClient + .resetIcon() + .then((result) => { + if (result.success === false) { + updateStatus(result.error.message); + renderSettingsPanel(); + return; + } + return applySettingsSnapshot(result).then(() => { + updateStatus('Reset toolbox icon.'); + }); + }) + .catch((error) => { + updateStatus( + error instanceof Error ? error.message : 'Failed to reset icon.', + ); + renderSettingsPanel(); + }); + }); + selectedActions.tuneButtonElement.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); - if (!selectedTargetElement || !lastSelectionContext) { + if (!selectedTargetElement || !pendingSingleSelectionContext) { updateStatus('No selection to adjust.'); return; } @@ -3199,9 +3817,9 @@ export const createSelectionToolkit = ( openTuningModal({ anchorElement: selectedActions.tuneButtonElement, element: selectedTargetElement, - selectionContext: lastSelectionContext, + selectionContext: pendingSingleSelectionContext, setSelectionContext: (nextSelectionContext) => { - lastSelectionContext = nextSelectionContext; + pendingSingleSelectionContext = nextSelectionContext; }, }); }); @@ -3209,10 +3827,11 @@ export const createSelectionToolkit = ( selectedActions.deleteButtonElement.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); - lastSelectionContext = null; + pendingSingleSelectionContext = null; hideSelectedOverlay(); hideDimOverlay(); closeTuningModal(); + renderMultiSelectionControls(); updateStatus('Cleared selected context.'); }); @@ -3263,23 +3882,49 @@ export const createSelectionToolkit = ( 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'); + if (isMultiSelectionMode) { + 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'); + return; + } + exitSelectionMode(); + isPanelOpen = true; + renderPanelVisibility(); + if (copyResult.success) { + updateStatus(copiedMessage); + } + return; } - exitSelectionMode(); - isPanelOpen = true; - renderPanelVisibility(); - if (copyResult.success) { - updateStatus(copiedMessage); + + if (pendingSingleSelectionContext) { + const selectionContextToCopy = + await enrichSelectionContextSourceSnippets( + pendingSingleSelectionContext, + ); + // Keep the enriched pending value available when clipboard access + // fails so Done can be retried without losing the transaction. + pendingSingleSelectionContext = selectionContextToCopy; + const copied = await copyText(toTextContext(selectionContextToCopy)); + if (!copied) { + updateStatus('Failed to copy context to clipboard'); + return; + } + lastSelectionContext = selectionContextToCopy; + exitSelectionMode(); + isPanelOpen = true; + renderPanelVisibility(); + updateStatus('Copied selected context'); + } else { + updateStatus('No selection to copy.'); } })(); }); @@ -3326,6 +3971,8 @@ export const createSelectionToolkit = ( document.addEventListener('mousemove', onMouseMove, true); document.addEventListener('click', onSelect, true); document.addEventListener('scroll', updateSelectionOverlays, true); + window.addEventListener('keydown', onGlobalKeyDown, true); + window.addEventListener('keyup', onGlobalKeyUp, true); window.addEventListener('resize', updateSelectionOverlays); return { diff --git a/packages/core/src/global.d.ts b/packages/core/src/global.d.ts index be1ac27..bbdcef1 100644 --- a/packages/core/src/global.d.ts +++ b/packages/core/src/global.d.ts @@ -1,5 +1,6 @@ import type { AgenticReactConfig, + AgenticReactSettingsClient, SelectionContext, ToolResultValue, ToolkitConfig, @@ -49,6 +50,7 @@ type AgenticReactRuntime = { error?: string; }>; registerTuningModalExtension: (extension: TuningModalExtension) => () => void; + settings: AgenticReactSettingsClient; }; declare global { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3b3cc68..3b8e42a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,9 @@ export { RuntimeBridgeServer } from './bridge/server.js'; +export { + AgenticReactSettingsEngine, + createAgenticReactSettingsEngine, + NodeSettingsStore, +} from './core/settings/node.js'; export { createStreamableHttpMcpHandler, initMcpServer } from './mcp/index.js'; export { buildSelectionContextForElement, @@ -12,6 +17,26 @@ export { } from './select.js'; export type { AgenticReactConfig, + AgenticReactAppearanceSettings, + AgenticReactProjectSettingsDefaults, + AgenticReactSettings, + AgenticReactSettingsBootstrap, + AgenticReactSettingsCapability, + AgenticReactSettingsClient, + AgenticReactSettingsError, + AgenticReactSettingsErrorCode, + AgenticReactSettingsRpcFailure, + AgenticReactSettingsRpcResult, + AgenticReactSettingsRpcSuccess, + AgenticReactSettingsSnapshot, + AgenticReactSettingsSource, + AgenticReactSettingsSources, + AgenticReactShortcutKey, + AgenticReactShortcutSettings, + AgenticReactToolkitConfig, + AgenticReactToolboxIconFilename, + AgenticReactToolboxIconMetadata, + AgenticReactToolboxIconMime, CustomClientFunction, CustomTool, JsonValue, diff --git a/packages/core/src/overlay.js b/packages/core/src/overlay.js index 8d76354..91ecee8 100644 --- a/packages/core/src/overlay.js +++ b/packages/core/src/overlay.js @@ -1,4 +1,5 @@ import * as bippy from 'bippy'; +import { createAgenticReactSettingsClient } from './core/settings/browser.js'; 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'; @@ -26,8 +27,16 @@ const registerCustomTool = (name, handler) => { }; const agenticReactConfig = target[__AGENTIC_REACT_CONFIG__] || {}; +const settingsClient = createAgenticReactSettingsClient({ + initialSettings: agenticReactConfig.settings, + request: (event, payload, timeoutMs) => + requestNodeBridge(event, payload, timeoutMs), +}); const selectionToolkit = createSelectionToolkit({ initialConfig: agenticReactConfig.toolkit, + settingsClient, + sourceLookup: (componentName, selector) => + requestNodeBridge('source:resolve', { componentName, selector }), }); const runtimeApi = { @@ -42,6 +51,7 @@ const runtimeApi = { selectionToolkit.copyLastSelectionContext(format), registerTuningModalExtension: (extension) => selectionToolkit.registerTuningModalExtension(extension), + settings: settingsClient, }; const builtInTools = { @@ -348,6 +358,64 @@ const getBridgeUrl = () => { return `${protocol}//${window.location.host}${BRIDGE_WS_PATH}`; }; +const pendingNodeRequests = new Map(); +let activeBridgeSocket = null; + +const createBrowserRequestId = () => { + const cryptoApi = target.crypto; + if (cryptoApi?.randomUUID) { + return cryptoApi.randomUUID(); + } + + return `browser-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +const requestNodeBridge = (event, payload, timeoutMs = 10000) => + new Promise((resolve, reject) => { + if ( + !activeBridgeSocket || + activeBridgeSocket.readyState !== WebSocket.OPEN + ) { + reject(new Error('Bridge connection is not open')); + return; + } + + const requestId = createBrowserRequestId(); + const timeoutId = setTimeout(() => { + pendingNodeRequests.delete(requestId); + reject(new Error(`Bridge request timed out for ${event}`)); + }, timeoutMs); + + pendingNodeRequests.set(requestId, { + resolve, + reject, + timeoutId, + }); + + try { + activeBridgeSocket.send( + JSON.stringify({ + type: 'bridge:request', + id: requestId, + event, + payload, + }), + ); + } catch (error) { + clearTimeout(timeoutId); + pendingNodeRequests.delete(requestId); + reject(error instanceof Error ? error : new Error('Bridge send failed')); + } + }); + +const rejectPendingNodeRequests = (message) => { + for (const [requestId, pendingRequest] of pendingNodeRequests) { + clearTimeout(pendingRequest.timeoutId); + pendingRequest.reject(new Error(message)); + pendingNodeRequests.delete(requestId); + } +}; + const connectBridge = () => { let socket; let shouldReconnect = true; @@ -386,6 +454,24 @@ const connectBridge = () => { return; } + if (message?.type === 'bridge:response') { + const pendingRequest = pendingNodeRequests.get(message.id); + if (!pendingRequest) { + return; + } + pendingNodeRequests.delete(message.id); + clearTimeout(pendingRequest.timeoutId); + + if (message.ok) { + pendingRequest.resolve(message.payload); + } else { + pendingRequest.reject( + new Error(message.error || 'Bridge response failed'), + ); + } + return; + } + if (message?.type !== 'bridge:request') { return; } @@ -416,11 +502,19 @@ const connectBridge = () => { }); socket.addEventListener('close', () => { + if (activeBridgeSocket === socket) { + activeBridgeSocket = null; + } + rejectPendingNodeRequests('Bridge connection closed before response'); if (shouldReconnect) { setTimeout(establishConnection, 1000); } }); + socket.addEventListener('open', () => { + activeBridgeSocket = socket; + }); + socket.addEventListener('error', () => { try { socket.close(); diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 392c0da..ff21b44 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1,4 +1,4 @@ -export type BridgeRequestEvent = +export type RuntimeBridgeRequestEvent = | 'highlight-component' | 'get-component-tree' | 'get-component-states' @@ -10,6 +10,21 @@ export type BridgeRequestEvent = | 'get-react-source-code' | 'custom-tool'; +export type SettingsBridgeRequestEvent = + | 'settings:get-effective' + | 'settings:update-shortcuts' + | 'settings:reset-shortcut' + | 'settings:apply-icon' + | 'settings:reset-icon' + | 'settings:reset-shortcuts'; + +export type SourceBridgeRequestEvent = 'source:resolve'; + +export type BridgeRequestEvent = + | RuntimeBridgeRequestEvent + | SettingsBridgeRequestEvent + | SourceBridgeRequestEvent; + export type BridgeMessage = | { type: 'bridge:request'; diff --git a/packages/core/src/shared/types.ts b/packages/core/src/shared/types.ts index 8d8e877..acf9dd8 100644 --- a/packages/core/src/shared/types.ts +++ b/packages/core/src/shared/types.ts @@ -60,6 +60,118 @@ export interface ToolkitTuningModalConfig { tokens?: Record; } +export type AgenticReactShortcutKey = + | 'singleSelect' + | 'multiSelect' + | 'toggleToolbox' + | 'done'; + +export type AgenticReactShortcutSettings = Record< + AgenticReactShortcutKey, + string +>; + +export type AgenticReactSettingsSource = 'global' | 'project' | 'package'; + +export type AgenticReactToolboxIconMime = 'image/webp' | 'image/png'; + +export type AgenticReactToolboxIconFilename = + | 'toolbox-icon.webp' + | 'toolbox-icon.png'; + +export interface AgenticReactToolboxIconMetadata { + filename: AgenticReactToolboxIconFilename; + mime: AgenticReactToolboxIconMime; + updatedAt: number; +} + +export interface AgenticReactAppearanceSettings { + toolboxIcon: AgenticReactToolboxIconMetadata | null; + toolboxIconUrl: string | null; +} + +export interface AgenticReactSettings { + schemaVersion: 1; + shortcuts: AgenticReactShortcutSettings; + appearance: AgenticReactAppearanceSettings; +} + +export interface AgenticReactSettingsSources { + shortcuts: Record; + appearance: { + toolboxIcon: AgenticReactSettingsSource; + }; +} + +export type AgenticReactSettingsErrorCode = + | 'settings_unavailable' + | 'unauthorized' + | 'invalid_payload' + | 'invalid_settings' + | 'unsupported_schema' + | 'read_failed' + | 'write_failed'; + +export interface AgenticReactSettingsError { + code: AgenticReactSettingsErrorCode; + message: string; + detail?: string; +} + +export interface AgenticReactSettingsSnapshot { + effectiveSettings: AgenticReactSettings; + sources: AgenticReactSettingsSources; + errors: AgenticReactSettingsError[]; +} + +export interface AgenticReactSettingsCapability { + available: boolean; + token?: string; + reason?: string; +} + +export interface AgenticReactSettingsBootstrap + extends AgenticReactSettingsSnapshot { + capability: AgenticReactSettingsCapability; +} + +export interface AgenticReactSettingsRpcSuccess + extends AgenticReactSettingsSnapshot { + success: true; +} + +export interface AgenticReactSettingsRpcFailure + extends AgenticReactSettingsSnapshot { + success: false; + error: AgenticReactSettingsError; +} + +export type AgenticReactSettingsRpcResult = + | AgenticReactSettingsRpcSuccess + | AgenticReactSettingsRpcFailure; + +export interface AgenticReactProjectSettingsDefaults { + shortcuts?: Partial; +} + +export interface AgenticReactSettingsClient { + getEffectiveSettings: () => Promise; + updateShortcuts: ( + shortcuts: Partial, + ) => Promise; + resetShortcut: ( + key: AgenticReactShortcutKey, + ) => Promise; + applyIcon: (input: { + data: string; + mime?: AgenticReactToolboxIconMime; + }) => Promise; + resetIcon: () => Promise; + resetShortcuts: () => Promise; + getCachedSnapshot: () => AgenticReactSettingsSnapshot; + getCapability: () => AgenticReactSettingsCapability; +} + export interface ToolkitConfig { enabled?: boolean; defaultVisible?: boolean; @@ -72,9 +184,14 @@ export interface ToolkitConfig { tuningModal?: ToolkitTuningModalConfig; } +export type AgenticReactToolkitConfig = ToolkitConfig & { + settings?: AgenticReactProjectSettingsDefaults; +}; + export interface AgenticReactConfig { - toolkit?: ToolkitConfig; + toolkit?: AgenticReactToolkitConfig; sourceRoot?: string; + settings?: AgenticReactSettingsBootstrap; } export interface SelectionStackFrame { diff --git a/packages/core/src/source_lookup.ts b/packages/core/src/source_lookup.ts new file mode 100644 index 0000000..043e97b --- /dev/null +++ b/packages/core/src/source_lookup.ts @@ -0,0 +1,162 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { RuntimeBridgeServer } from './bridge/server.js'; +import type { SelectionResolvedSource } from './shared/types.js'; + +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, +): void => { + 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; +}; + +export 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; +}; + +interface SourceLookupPayload { + componentName?: unknown; + selector?: unknown; +} + +export const registerSourceLookupHandler = ( + runtimeBridge: Pick, + rootDir: string, +): void => { + runtimeBridge.registerHandler('source:resolve', (payload) => { + const sourceLookup = (payload || {}) as SourceLookupPayload; + if ( + typeof sourceLookup.componentName !== 'string' || + typeof sourceLookup.selector !== 'string' + ) { + return null; + } + + return findComponentSourceInProject( + rootDir, + sourceLookup.componentName, + sourceLookup.selector, + ); + }); +}; diff --git a/packages/core/test/image-cropper.test.mjs b/packages/core/test/image-cropper.test.mjs new file mode 100644 index 0000000..2bcabf8 --- /dev/null +++ b/packages/core/test/image-cropper.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createDefaultImageCropState, + dragImageCropState, + getImageCropCoverZoom, + getRotatedImageDimensions, + rotateImageCropState, + zoomImageCropState, +} from '../dist/core/settings/image_cropper.js'; + +const wideImage = { width: 400, height: 200 }; + +test('default crop state centers a square cover crop', () => { + const state = createDefaultImageCropState(wideImage); + + assert.equal(state.offsetX, 0); + assert.equal(state.offsetY, 0); + assert.equal(state.rotation, 0); + assert.equal(state.zoom, 1.28); +}); + +test('dragging crop state clamps offsets to covered pixels', () => { + const state = createDefaultImageCropState(wideImage); + const dragged = dragImageCropState( + state, + { deltaX: 1000, deltaY: 1000 }, + wideImage, + ); + + assert.equal(dragged.offsetX, 128); + assert.equal(dragged.offsetY, 0); +}); + +test('zoom cannot go below cover scale', () => { + const state = createDefaultImageCropState(wideImage); + const zoomed = zoomImageCropState(state, 0.5, wideImage); + + assert.equal(zoomed.zoom, getImageCropCoverZoom(wideImage)); +}); + +test('right angle rotation swaps cover dimensions and preserves bounds', () => { + const rotatedDimensions = getRotatedImageDimensions(wideImage, 90); + const rotated = rotateImageCropState( + createDefaultImageCropState(wideImage), + 'right', + wideImage, + ); + + assert.deepEqual(rotatedDimensions, { width: 200, height: 400 }); + assert.equal(rotated.rotation, 90); + assert.equal(rotated.zoom, 1.28); +}); diff --git a/packages/core/test/settings.test.mjs b/packages/core/test/settings.test.mjs new file mode 100644 index 0000000..765126a --- /dev/null +++ b/packages/core/test/settings.test.mjs @@ -0,0 +1,685 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { WebSocket } from 'ws'; +import { RuntimeBridgeServer } from '../dist/bridge/server.js'; +import { + createAgenticReactSettingsEngine, + NodeSettingsStore, +} from '../dist/core/settings/node.js'; + +const createSettingsRoot = () => + fs.mkdtempSync(path.join(os.tmpdir(), 'agentic-react-settings-')); + +const cleanup = (directory) => + fs.rmSync(directory, { recursive: true, force: true }); + +const readSettingsJson = (settingsRoot) => + JSON.parse(fs.readFileSync(path.join(settingsRoot, 'settings.json'), 'utf8')); + +const pngBytes = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, +]); + +const otherPngBytes = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, +]); + +const webpBytes = Buffer.from([ + 0x52, 0x49, 0x46, 0x46, 0x04, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, +]); + +const createSettingsCommitFailingFileSystem = () => ({ + mkdirSync: fs.mkdirSync.bind(fs), + readFileSync: fs.readFileSync.bind(fs), + renameSync: (source, target) => { + if (path.basename(String(target)) === 'settings.json') { + throw new Error('settings commit failed'); + } + fs.renameSync(source, target); + }, + rmSync: fs.rmSync.bind(fs), + writeFileSync: fs.writeFileSync.bind(fs), +}); + +const requestSettingsRpc = async ({ address, id, event, payload }) => + new Promise((resolve, reject) => { + const socket = new WebSocket( + `ws://127.0.0.1:${address.port}/__agentic_react_bridge`, + ); + const timeout = setTimeout(() => { + socket.close(); + reject(new Error('Timed out waiting for settings RPC response')); + }, 5000); + + socket.on('open', () => { + socket.send( + JSON.stringify({ + type: 'bridge:request', + id, + event, + payload, + }), + ); + }); + socket.on('message', (message) => { + clearTimeout(timeout); + socket.close(); + resolve(JSON.parse(message.toString())); + }); + socket.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + }); + +test('store path override keeps settings under injected root', () => { + const settingsRoot = createSettingsRoot(); + try { + const store = new NodeSettingsStore({ settingsRoot }); + const result = store.updateShortcuts({ singleSelect: 'Ctrl+1' }); + + assert.equal(result.success, true); + assert.equal(store.getSettingsRootForTests(), settingsRoot); + assert.equal( + store.getSettingsFilePathForTests(), + path.join(settingsRoot, 'settings.json'), + ); + assert.equal(fs.existsSync(path.join(settingsRoot, 'settings.json')), true); + } finally { + cleanup(settingsRoot); + } +}); + +test('effective settings merge global over project over package defaults', () => { + const settingsRoot = createSettingsRoot(); + try { + fs.mkdirSync(settingsRoot, { recursive: true }); + fs.writeFileSync( + path.join(settingsRoot, 'settings.json'), + JSON.stringify({ + schemaVersion: 1, + shortcuts: { + singleSelect: 'Ctrl+G', + }, + }), + ); + + const store = new NodeSettingsStore({ + settingsRoot, + projectToolkitConfig: { + settings: { + shortcuts: { + singleSelect: 'Ctrl+P', + multiSelect: 'Shift+M', + }, + }, + }, + }); + const snapshot = store.getSnapshot(); + + assert.equal(snapshot.effectiveSettings.shortcuts.singleSelect, 'Ctrl+G'); + assert.equal(snapshot.sources.shortcuts.singleSelect, 'global'); + assert.equal(snapshot.effectiveSettings.shortcuts.multiSelect, 'Shift+M'); + assert.equal(snapshot.sources.shortcuts.multiSelect, 'project'); + assert.equal( + snapshot.effectiveSettings.shortcuts.toggleToolbox, + 'Ctrl+Alt+Shift+A', + ); + assert.equal(snapshot.effectiveSettings.shortcuts.done, 'Enter'); + assert.equal(snapshot.sources.shortcuts.done, 'package'); + } finally { + cleanup(settingsRoot); + } +}); + +test('corrupt settings file falls back with structured errors', () => { + const settingsRoot = createSettingsRoot(); + try { + fs.mkdirSync(settingsRoot, { recursive: true }); + fs.writeFileSync(path.join(settingsRoot, 'settings.json'), '{bad json'); + + const snapshot = new NodeSettingsStore({ settingsRoot }).getSnapshot(); + + assert.equal(snapshot.effectiveSettings.shortcuts.done, 'Enter'); + assert.equal( + snapshot.effectiveSettings.shortcuts.singleSelect, + 'Ctrl+Alt+Shift+S', + ); + assert.equal(snapshot.errors[0].code, 'invalid_settings'); + } finally { + cleanup(settingsRoot); + } +}); + +test('shortcut updates are persisted without temp leftovers', () => { + const settingsRoot = createSettingsRoot(); + try { + const store = new NodeSettingsStore({ settingsRoot }); + const result = store.updateShortcuts({ + singleSelect: 'Ctrl+S', + done: 'Ctrl+Enter', + }); + + assert.equal(result.success, true); + assert.deepEqual(readSettingsJson(settingsRoot).shortcuts, { + singleSelect: 'Ctrl+S', + done: 'Ctrl+Enter', + }); + assert.equal( + fs.readdirSync(settingsRoot).some((entry) => entry.endsWith('.tmp')), + false, + ); + } finally { + cleanup(settingsRoot); + } +}); + +test('shortcut writes normalize valid values and reject unsafe or duplicate sets', () => { + const settingsRoot = createSettingsRoot(); + try { + const store = new NodeSettingsStore({ settingsRoot }); + const normalizedResult = store.updateShortcuts({ + singleSelect: 'shift + ctrl + 1', + }); + assert.equal(normalizedResult.success, true); + assert.equal( + readSettingsJson(settingsRoot).shortcuts.singleSelect, + 'Ctrl+Shift+1', + ); + const committedSettings = readSettingsJson(settingsRoot); + + for (const shortcut of ['Escape', 'Ctrl+Escape', 'Shift', 'Ctrl+Private']) { + const result = store.updateShortcuts({ singleSelect: shortcut }); + assert.equal(result.success, false); + assert.equal(result.error.code, 'invalid_payload'); + assert.deepEqual(readSettingsJson(settingsRoot), committedSettings); + } + + const duplicateResult = store.updateShortcuts({ + singleSelect: 'Enter', + }); + assert.equal(duplicateResult.success, false); + assert.equal(duplicateResult.error.code, 'invalid_payload'); + assert.match(duplicateResult.error.message, /already assigned/i); + assert.deepEqual(readSettingsJson(settingsRoot), committedSettings); + } finally { + cleanup(settingsRoot); + } +}); + +test('shortcut writes reject duplicates after merging project defaults', () => { + const settingsRoot = createSettingsRoot(); + try { + const store = new NodeSettingsStore({ + settingsRoot, + projectToolkitConfig: { + settings: { shortcuts: { multiSelect: 'Ctrl+P' } }, + }, + }); + + const result = store.updateShortcuts({ singleSelect: 'ctrl+p' }); + assert.equal(result.success, false); + assert.equal(result.error.code, 'invalid_payload'); + assert.equal(fs.existsSync(path.join(settingsRoot, 'settings.json')), false); + } finally { + cleanup(settingsRoot); + } +}); + +test('single shortcut reset reveals project or package defaults', () => { + const settingsRoot = createSettingsRoot(); + try { + const store = new NodeSettingsStore({ + settingsRoot, + projectToolkitConfig: { + settings: { + shortcuts: { + singleSelect: 'Ctrl+P', + }, + }, + }, + }); + const updateResult = store.updateShortcuts({ + singleSelect: 'Ctrl+G', + multiSelect: 'Ctrl+M', + }); + assert.equal(updateResult.success, true); + + const singleResetResult = store.resetShortcut('singleSelect'); + assert.equal(singleResetResult.success, true); + assert.equal( + singleResetResult.effectiveSettings.shortcuts.singleSelect, + 'Ctrl+P', + ); + assert.equal(singleResetResult.sources.shortcuts.singleSelect, 'project'); + assert.equal( + singleResetResult.effectiveSettings.shortcuts.multiSelect, + 'Ctrl+M', + ); + assert.deepEqual(readSettingsJson(settingsRoot).shortcuts, { + multiSelect: 'Ctrl+M', + }); + + const multiResetResult = store.resetShortcut('multiSelect'); + assert.equal(multiResetResult.success, true); + assert.equal( + multiResetResult.effectiveSettings.shortcuts.multiSelect, + 'Ctrl+Alt+Shift+M', + ); + assert.equal(multiResetResult.sources.shortcuts.multiSelect, 'package'); + assert.equal( + Object.hasOwn(readSettingsJson(settingsRoot), 'shortcuts'), + false, + ); + } finally { + cleanup(settingsRoot); + } +}); + +test('icon validation, apply, and reset use fixed files only', () => { + const settingsRoot = createSettingsRoot(); + try { + const store = new NodeSettingsStore({ settingsRoot }); + const invalidResult = store.applyIcon({ + mime: 'image/png', + data: Buffer.from('not a png').toString('base64'), + }); + + assert.equal(invalidResult.success, false); + assert.equal(invalidResult.error.code, 'invalid_payload'); + + const applyResult = store.applyIcon({ + mime: 'image/png', + data: pngBytes.toString('base64'), + }); + + assert.equal(applyResult.success, true); + assert.equal( + applyResult.effectiveSettings.appearance.toolboxIcon.filename, + 'toolbox-icon.png', + ); + assert.equal( + applyResult.effectiveSettings.appearance.toolboxIconUrl, + `data:image/png;base64,${pngBytes.toString('base64')}`, + ); + assert.equal(fs.existsSync(path.join(settingsRoot, 'toolbox-icon.png')), true); + assert.equal( + fs.existsSync(path.join(settingsRoot, 'toolbox-icon.webp')), + false, + ); + + const resetResult = store.resetIcon(); + + assert.equal(resetResult.success, true); + assert.equal(resetResult.effectiveSettings.appearance.toolboxIcon, null); + assert.equal(fs.existsSync(path.join(settingsRoot, 'toolbox-icon.png')), false); + } finally { + cleanup(settingsRoot); + } +}); + +test('global icon resolves after reload and takes precedence over project iconUrl', () => { + const settingsRoot = createSettingsRoot(); + try { + const projectIconUrl = '/agentic-react-project-icon.png'; + const store = new NodeSettingsStore({ + settingsRoot, + projectToolkitConfig: { + iconUrl: projectIconUrl, + }, + }); + + const projectSnapshot = store.getSnapshot(); + assert.equal( + projectSnapshot.effectiveSettings.appearance.toolboxIcon, + null, + ); + assert.equal( + projectSnapshot.effectiveSettings.appearance.toolboxIconUrl, + projectIconUrl, + ); + assert.equal(projectSnapshot.sources.appearance.toolboxIcon, 'project'); + + const applyResult = store.applyIcon({ + mime: 'image/png', + data: pngBytes.toString('base64'), + }); + assert.equal(applyResult.success, true); + + const reloadedSnapshot = new NodeSettingsStore({ + settingsRoot, + projectToolkitConfig: { + iconUrl: projectIconUrl, + }, + }).getSnapshot(); + + assert.equal(reloadedSnapshot.sources.appearance.toolboxIcon, 'global'); + assert.equal( + reloadedSnapshot.effectiveSettings.appearance.toolboxIcon.filename, + 'toolbox-icon.png', + ); + assert.equal( + reloadedSnapshot.effectiveSettings.appearance.toolboxIconUrl, + `data:image/png;base64,${pngBytes.toString('base64')}`, + ); + } finally { + cleanup(settingsRoot); + } +}); + +test('project iconUrl fallback omits local home paths from snapshots', () => { + const settingsRoot = createSettingsRoot(); + try { + const snapshot = new NodeSettingsStore({ + settingsRoot, + projectToolkitConfig: { + iconUrl: '/Users/example/private-icon.png', + }, + }).getSnapshot(); + + assert.equal(snapshot.sources.appearance.toolboxIcon, 'package'); + assert.equal(snapshot.effectiveSettings.appearance.toolboxIconUrl, null); + } finally { + cleanup(settingsRoot); + } +}); + +test('missing or corrupt global icons fall back without exposing broken global data', () => { + const settingsRoot = createSettingsRoot(); + try { + fs.mkdirSync(settingsRoot, { recursive: true }); + fs.writeFileSync( + path.join(settingsRoot, 'settings.json'), + JSON.stringify({ + schemaVersion: 1, + appearance: { + toolboxIcon: { + filename: 'toolbox-icon.png', + mime: 'image/png', + updatedAt: Date.now(), + }, + }, + }), + ); + + const missingSnapshot = new NodeSettingsStore({ + settingsRoot, + projectToolkitConfig: { + iconUrl: '/project-icon.png', + }, + }).getSnapshot(); + + assert.equal(missingSnapshot.sources.appearance.toolboxIcon, 'project'); + assert.equal( + missingSnapshot.effectiveSettings.appearance.toolboxIcon, + null, + ); + assert.equal( + missingSnapshot.effectiveSettings.appearance.toolboxIconUrl, + '/project-icon.png', + ); + assert.equal(missingSnapshot.errors[0].code, 'invalid_settings'); + assert.equal(missingSnapshot.errors[0].detail, 'toolbox-icon.png'); + + fs.writeFileSync(path.join(settingsRoot, 'toolbox-icon.png'), 'not a png'); + const corruptSnapshot = new NodeSettingsStore({ settingsRoot }).getSnapshot(); + + assert.equal(corruptSnapshot.sources.appearance.toolboxIcon, 'package'); + assert.equal(corruptSnapshot.effectiveSettings.appearance.toolboxIcon, null); + assert.equal( + corruptSnapshot.effectiveSettings.appearance.toolboxIconUrl, + null, + ); + assert.equal(corruptSnapshot.errors[0].code, 'invalid_settings'); + } finally { + cleanup(settingsRoot); + } +}); + +test('icon apply preserves committed state when metadata write fails', () => { + const settingsRoot = createSettingsRoot(); + try { + const store = new NodeSettingsStore({ settingsRoot }); + const initialResult = store.applyIcon({ + mime: 'image/webp', + data: webpBytes.toString('base64'), + }); + assert.equal(initialResult.success, true); + const committedSettings = readSettingsJson(settingsRoot); + + const failingStore = new NodeSettingsStore({ + settingsRoot, + fileSystem: createSettingsCommitFailingFileSystem(), + }); + const failedResult = failingStore.applyIcon({ + mime: 'image/png', + data: pngBytes.toString('base64'), + }); + + assert.equal(failedResult.success, false); + assert.equal(failedResult.error.code, 'write_failed'); + assert.deepEqual(readSettingsJson(settingsRoot), committedSettings); + assert.equal(fs.existsSync(path.join(settingsRoot, 'toolbox-icon.png')), false); + assert.deepEqual( + fs.readFileSync(path.join(settingsRoot, 'toolbox-icon.webp')), + webpBytes, + ); + assert.equal( + failedResult.effectiveSettings.appearance.toolboxIcon.filename, + 'toolbox-icon.webp', + ); + assert.equal( + failedResult.effectiveSettings.appearance.toolboxIconUrl, + `data:image/webp;base64,${webpBytes.toString('base64')}`, + ); + } finally { + cleanup(settingsRoot); + } +}); + +test('icon apply restores previous bytes on same-target metadata write failure', () => { + const settingsRoot = createSettingsRoot(); + try { + const store = new NodeSettingsStore({ settingsRoot }); + assert.equal( + store.applyIcon({ + mime: 'image/png', + data: pngBytes.toString('base64'), + }).success, + true, + ); + + const failingStore = new NodeSettingsStore({ + settingsRoot, + fileSystem: createSettingsCommitFailingFileSystem(), + }); + const failedResult = failingStore.applyIcon({ + mime: 'image/png', + data: otherPngBytes.toString('base64'), + }); + + assert.equal(failedResult.success, false); + assert.deepEqual( + fs.readFileSync(path.join(settingsRoot, 'toolbox-icon.png')), + pngBytes, + ); + assert.equal( + failedResult.effectiveSettings.appearance.toolboxIconUrl, + `data:image/png;base64,${pngBytes.toString('base64')}`, + ); + } finally { + cleanup(settingsRoot); + } +}); + +test('icon reset preserves committed files when metadata write fails', () => { + const settingsRoot = createSettingsRoot(); + try { + const store = new NodeSettingsStore({ settingsRoot }); + assert.equal( + store.applyIcon({ + mime: 'image/png', + data: pngBytes.toString('base64'), + }).success, + true, + ); + const committedSettings = readSettingsJson(settingsRoot); + + const failingStore = new NodeSettingsStore({ + settingsRoot, + fileSystem: createSettingsCommitFailingFileSystem(), + }); + const failedResult = failingStore.resetIcon(); + + assert.equal(failedResult.success, false); + assert.equal(failedResult.error.code, 'write_failed'); + assert.deepEqual(readSettingsJson(settingsRoot), committedSettings); + assert.deepEqual( + fs.readFileSync(path.join(settingsRoot, 'toolbox-icon.png')), + pngBytes, + ); + } finally { + cleanup(settingsRoot); + } +}); + +test('bridge supports browser to Node settings RPC on the existing socket', async () => { + const settingsRoot = createSettingsRoot(); + const httpServer = http.createServer(); + const runtimeBridge = new RuntimeBridgeServer(); + const settingsEngine = createAgenticReactSettingsEngine({ settingsRoot }); + runtimeBridge.attach(httpServer); + settingsEngine.registerBridge(runtimeBridge); + + try { + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const address = httpServer.address(); + assert.equal(typeof address, 'object'); + + const response = await requestSettingsRpc({ + address, + id: 'settings-rpc-1', + event: 'settings:update-shortcuts', + payload: { + token: settingsEngine.token, + shortcuts: { + singleSelect: 'Ctrl+B', + }, + }, + }); + + assert.equal(response.type, 'bridge:response'); + assert.equal(response.id, 'settings-rpc-1'); + assert.equal(response.ok, true); + assert.equal(response.payload.success, true); + assert.equal( + response.payload.effectiveSettings.shortcuts.singleSelect, + 'Ctrl+B', + ); + } finally { + await new Promise((resolve) => httpServer.close(resolve)); + cleanup(settingsRoot); + } +}); + +test('bridge rejects invalid and duplicate shortcut updates', async () => { + const settingsRoot = createSettingsRoot(); + const httpServer = http.createServer(); + const runtimeBridge = new RuntimeBridgeServer(); + const settingsEngine = createAgenticReactSettingsEngine({ settingsRoot }); + runtimeBridge.attach(httpServer); + settingsEngine.registerBridge(runtimeBridge); + + try { + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const address = httpServer.address(); + assert.equal(typeof address, 'object'); + + for (const [id, shortcut] of [ + ['settings-rpc-reserved', 'Escape'], + ['settings-rpc-duplicate', 'Enter'], + ]) { + const response = await requestSettingsRpc({ + address, + id, + event: 'settings:update-shortcuts', + payload: { + token: settingsEngine.token, + shortcuts: { singleSelect: shortcut }, + }, + }); + assert.equal(response.ok, true); + assert.equal(response.payload.success, false); + assert.equal(response.payload.error.code, 'invalid_payload'); + } + assert.equal(fs.existsSync(path.join(settingsRoot, 'settings.json')), false); + } finally { + await new Promise((resolve) => httpServer.close(resolve)); + cleanup(settingsRoot); + } +}); + +test('bridge settings RPC authorization failures do not disclose global settings', async () => { + const settingsRoot = createSettingsRoot(); + const httpServer = http.createServer(); + const runtimeBridge = new RuntimeBridgeServer(); + const settingsEngine = createAgenticReactSettingsEngine({ settingsRoot }); + runtimeBridge.attach(httpServer); + settingsEngine.registerBridge(runtimeBridge); + + try { + fs.mkdirSync(settingsRoot, { recursive: true }); + fs.writeFileSync( + path.join(settingsRoot, 'settings.json'), + JSON.stringify({ + schemaVersion: 1, + shortcuts: { + singleSelect: 'Ctrl+Private', + multiSelect: 'Ctrl+Alt+Private', + }, + }), + ); + + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const address = httpServer.address(); + assert.equal(typeof address, 'object'); + + for (const [id, payload] of [ + ['settings-rpc-missing-token', {}], + ['settings-rpc-bad-token', { token: 'bad-token' }], + ]) { + const response = await requestSettingsRpc({ + address, + id, + event: 'settings:get-effective', + payload, + }); + + assert.equal(response.type, 'bridge:response'); + assert.equal(response.id, id); + assert.equal(response.ok, true); + assert.equal(response.payload.success, false); + assert.equal(response.payload.error.code, 'unauthorized'); + assert.equal( + response.payload.effectiveSettings.shortcuts.singleSelect, + 'Ctrl+Alt+Shift+S', + ); + assert.equal( + response.payload.effectiveSettings.shortcuts.multiSelect, + 'Ctrl+Alt+Shift+M', + ); + assert.equal( + response.payload.effectiveSettings.appearance.toolboxIconUrl, + null, + ); + assert.equal(response.payload.sources.shortcuts.singleSelect, 'package'); + } + } finally { + await new Promise((resolve) => httpServer.close(resolve)); + cleanup(settingsRoot); + } +}); diff --git a/packages/core/test/shortcuts.test.mjs b/packages/core/test/shortcuts.test.mjs new file mode 100644 index 0000000..7b84b69 --- /dev/null +++ b/packages/core/test/shortcuts.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createEscapeKeyCycleGuard, + findDuplicateShortcut, + normalizeShortcutString, + validateShortcutSettings, +} from '../dist/core/settings/shortcuts.js'; + +test('shortcut normalization orders modifiers and uses platform names', () => { + assert.deepEqual(normalizeShortcutString('shift + alt + ctrl + s', 'Win32'), { + success: true, + label: 'Ctrl+Alt+Shift+S', + identity: 'ctrl+alt+shift+s', + }); + assert.deepEqual(normalizeShortcutString('cmd+option+k', 'MacIntel'), { + success: true, + label: 'Option+Command+K', + identity: 'alt+meta+k', + }); +}); + +test('shortcut normalization rejects reserved or incomplete shortcuts', () => { + assert.equal(normalizeShortcutString('Shift').success, false); + assert.equal(normalizeShortcutString('Escape').success, false); + assert.equal(normalizeShortcutString('Ctrl+Escape').success, false); + assert.equal(normalizeShortcutString('Ctrl+A+B').success, false); +}); + +test('duplicate shortcut detection compares normalized identities', () => { + const shortcuts = { + singleSelect: 'Ctrl+Alt+Shift+S', + multiSelect: 'Shift+Ctrl+M', + toggleToolbox: 'Ctrl+Alt+Shift+A', + done: 'Enter', + }; + + assert.equal( + findDuplicateShortcut('ctrl+shift+m', shortcuts, 'singleSelect', 'Win32'), + 'multiSelect', + ); + assert.equal( + findDuplicateShortcut('Ctrl+Alt+Shift+S', shortcuts, 'singleSelect', 'Win32'), + null, + ); +}); + +test('complete shortcut validation normalizes labels and rejects duplicates', () => { + const normalized = validateShortcutSettings( + { + singleSelect: 'shift+ctrl+s', + multiSelect: 'Ctrl+M', + toggleToolbox: 'Ctrl+A', + done: 'Enter', + }, + 'Win32', + ); + assert.equal(normalized.success, true); + assert.equal(normalized.shortcuts.singleSelect, 'Ctrl+Shift+S'); + + const duplicate = validateShortcutSettings( + { + singleSelect: 'Shift+Ctrl+S', + multiSelect: 'ctrl+shift+s', + toggleToolbox: 'Ctrl+A', + done: 'Enter', + }, + 'Win32', + ); + assert.equal(duplicate.success, false); + assert.equal(duplicate.action, 'multiSelect'); + assert.equal(duplicate.duplicateAction, 'singleSelect'); +}); + +test('Escape key cycle guard captures repeat keydowns through keyup', () => { + const guard = createEscapeKeyCycleGuard(); + + assert.equal(guard.handleKeyDown('Escape', false), 'ignore'); + assert.equal(guard.handleKeyDown('Escape', true), 'initial'); + assert.equal(guard.handleKeyDown('Escape', false), 'repeat'); + assert.equal(guard.handleKeyDown('Escape', false), 'repeat'); + assert.equal(guard.handleKeyUp('Escape'), true); + assert.equal(guard.handleKeyDown('Escape', false), 'ignore'); + assert.equal(guard.handleKeyUp('Escape'), false); +}); diff --git a/packages/core/test/source-lookup.test.mjs b/packages/core/test/source-lookup.test.mjs new file mode 100644 index 0000000..735790b --- /dev/null +++ b/packages/core/test/source-lookup.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + findComponentSourceInProject, + registerSourceLookupHandler, +} from '../dist/source_lookup.js'; + +test('source lookup resolves a component and refines its selector line', () => { + const rootDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'agentic-react-source-lookup-'), + ); + const sourceDirectory = path.join(rootDir, 'components'); + fs.mkdirSync(sourceDirectory); + fs.writeFileSync( + path.join(sourceDirectory, 'hero.tsx'), + [ + 'export function InteriorHero() {', + ' return (', + '
', + ' ', + '
', + ' );', + '}', + ].join('\n'), + ); + + try { + assert.deepEqual( + findComponentSourceInProject( + rootDir, + 'InteriorHero', + '.interior-map > div > svg', + ), + { + filePath: 'components/hero.tsx', + lineNumber: 4, + columnNumber: null, + componentName: 'InteriorHero', + }, + ); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test('bridge handler exposes source lookup to browser clients', () => { + const rootDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'agentic-react-source-bridge-'), + ); + fs.writeFileSync( + path.join(rootDir, 'blog-page.jsx'), + [ + 'export const BlogPage = () => (', + '
Notes
', + ');', + ].join('\n'), + ); + let registeredEvent; + let registeredHandler; + + try { + registerSourceLookupHandler( + { + registerHandler(event, handler) { + registeredEvent = event; + registeredHandler = handler; + }, + }, + rootDir, + ); + + assert.equal(registeredEvent, 'source:resolve'); + assert.deepEqual( + registeredHandler({ + componentName: 'BlogPage', + selector: '#notes', + }), + { + filePath: 'blog-page.jsx', + lineNumber: 2, + columnNumber: null, + componentName: 'BlogPage', + }, + ); + assert.equal(registeredHandler({ componentName: '../etc/passwd' }), null); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); diff --git a/packages/next/README.md b/packages/next/README.md index 038570d..a2c05cc 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -14,6 +14,38 @@ 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`. +## Settings Configuration + +The Next adapter enables the toolbox Settings section when its local bridge server is enabled. User overrides are global to the machine under `~/.agentic-react/` and take precedence over project defaults and package defaults. + +Use `settingsRoot` only when you need an isolated settings directory for tests, demos, or temporary environments. Project defaults are configured with `toolkit.settings.shortcuts`, and the project default launcher icon is configured with `toolkit.iconUrl`. + +```ts +import withAgenticReactNext from '@agentic-react/next'; +import type { AgenticReactToolkitConfig } from '@agentic-react/next'; + +const toolkit: AgenticReactToolkitConfig = { + iconUrl: '/agentic-react-logo.png', + settings: { + shortcuts: { + singleSelect: 'Ctrl+Shift+S', + multiSelect: 'Ctrl+Shift+M', + toggleToolbox: 'Ctrl+Shift+A', + done: 'Enter', + }, + }, +}; + +export default withAgenticReactNext(nextConfig, { + settingsRoot: '/tmp/agentic-react-settings', + toolkit, +}); +``` + +The Settings UI can change shortcuts and crop a custom toolbox icon. Reset removes the global override and falls back to the project default when present, otherwise to the package default. `Escape` is reserved for cancelling pending selection and cannot be assigned. If `server.enabled` is `false`, persistent Settings are unavailable because the browser has no adapter-hosted bridge. + +Clicking a target captures a pending selection but does not copy it. Click Done, or press the configured Done shortcut, to copy and commit the pending selection; multiselect copies the complete pending set. + ## Tuning Modal Configuration Pass tuning modal options as the second argument to `withAgenticReactNext`. The `toolkit` object uses the shared `ToolkitConfig` shape; `toolkit.tuningModal` accepts `classNames`, `styles`, and `tokens`. diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index fdd6c47..1c48a09 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import http from 'node:http'; import { createRequire } from 'node:module'; import path from 'node:path'; +import { createAgenticReactSettingsEngine } from '@agentic-react/core'; import { RuntimeBridgeServer } from '@agentic-react/core/bridge'; import { createStreamableHttpMcpHandler, @@ -18,9 +19,11 @@ import { } from '@agentic-react/core/shared/custom-tools-script'; import { BRIDGE_WS_PATH } from '@agentic-react/core/shared/protocol'; import type { + AgenticReactSettingsBootstrap, + AgenticReactToolkitConfig, CustomTool, - ToolkitConfig, } from '@agentic-react/core/shared/types'; +import { registerSourceLookupHandler } from '@agentic-react/core/source-lookup'; interface NextWebpackContext { dev: boolean; @@ -38,7 +41,8 @@ interface NextConfig { export interface AgenticReactNextOptions { customTools?: CustomTool[]; rootDir?: string; - toolkit?: ToolkitConfig; + settingsRoot?: string; + toolkit?: AgenticReactToolkitConfig; bridgeUrl?: string; server?: { enabled?: boolean; @@ -50,6 +54,7 @@ export interface AgenticReactNextOptions { interface BridgeServerState { started: boolean; server?: http.Server; + settingsEngine: ReturnType; } const DEFAULT_BRIDGE_HOST = '127.0.0.1'; @@ -65,7 +70,8 @@ const writeNextClientEntry = ( rootDir: string, bridgeUrl: string | null, customTools: CustomTool[], - toolkitConfig: ToolkitConfig, + toolkitConfig: AgenticReactToolkitConfig, + settingsBootstrap: AgenticReactSettingsBootstrap, ): string => { const coreDistPath = getCoreDistPath(); const generatedDirectory = path.join(rootDir, '.agentic-react-next'); @@ -104,6 +110,7 @@ if (typeof window !== 'undefined') { ...(existingAgenticReactConfig.toolkit || {}), ...${JSON.stringify(toolkitConfig)}, }, + settings: existingAgenticReactConfig.settings || ${JSON.stringify(settingsBootstrap)}, }; if (!window[${__AGENTIC_REACT_BRIDGE_URL__}]) { @@ -169,8 +176,14 @@ const getBridgeServerRegistry = (): Map => { return globalRecord[NEXT_BRIDGE_STATE_KEY]; }; -const createBridgeHttpServer = (rootDir: string, customTools: CustomTool[]) => { +const createBridgeHttpServer = ( + rootDir: string, + customTools: CustomTool[], + settingsEngine: ReturnType, +) => { const runtimeBridge = new RuntimeBridgeServer(); + registerSourceLookupHandler(runtimeBridge, rootDir); + settingsEngine.registerBridge(runtimeBridge); const handleMcpRequest = createStreamableHttpMcpHandler(() => initMcpServer(runtimeBridge, rootDir, customTools), ); @@ -201,9 +214,27 @@ const createBridgeHttpServer = (rootDir: string, customTools: CustomTool[]) => { const startNextBridgeServer = ( options: AgenticReactNextOptions, -): string | null => { +): { + bridgeUrl: string | null; + settingsBootstrap: AgenticReactSettingsBootstrap; +} => { + const projectToolkitConfig = options.toolkit || {}; + const disabledSettingsEngine = createAgenticReactSettingsEngine({ + projectToolkitConfig, + settingsRoot: options.settingsRoot, + }); + if (options.server?.enabled === false) { - return options.bridgeUrl || null; + return { + bridgeUrl: options.bridgeUrl || null, + settingsBootstrap: { + ...disabledSettingsEngine.store.getSnapshot(), + capability: { + available: false, + reason: 'Next bridge server is disabled.', + }, + }, + }; } const host = options.server?.host || DEFAULT_BRIDGE_HOST; @@ -215,14 +246,25 @@ const startNextBridgeServer = ( const existingState = registry.get(registryKey); if (existingState) { - return bridgeUrl; + return { + bridgeUrl, + settingsBootstrap: existingState.settingsEngine.getBootstrap(), + }; } - const serverState: BridgeServerState = { started: false }; + const settingsEngine = createAgenticReactSettingsEngine({ + projectToolkitConfig, + settingsRoot: options.settingsRoot, + }); + const serverState: BridgeServerState = { started: false, settingsEngine }; registry.set(registryKey, serverState); const rootDir = options.rootDir || process.cwd(); - const httpServer = createBridgeHttpServer(rootDir, options.customTools || []); + const httpServer = createBridgeHttpServer( + rootDir, + options.customTools || [], + settingsEngine, + ); serverState.server = httpServer; httpServer.once('error', (error) => { @@ -241,7 +283,10 @@ const startNextBridgeServer = ( ); }); - return bridgeUrl; + return { + bridgeUrl, + settingsBootstrap: settingsEngine.getBootstrap(), + }; }; export const withAgenticReactNext = ( @@ -262,7 +307,7 @@ export const withAgenticReactNext = ( } const rootDir = options.rootDir || process.cwd(); - const bridgeUrl = startNextBridgeServer(options); + const { bridgeUrl, settingsBootstrap } = startNextBridgeServer(options); const originalEntry = transformedConfig.entry; transformedConfig.entry = async () => { @@ -271,6 +316,7 @@ export const withAgenticReactNext = ( bridgeUrl, options.customTools || [], options.toolkit || {}, + settingsBootstrap, ); const resolvedEntry = typeof originalEntry === 'function' @@ -288,6 +334,26 @@ export const withAgenticReactNext = ( export default withAgenticReactNext; export type { AgenticReactConfig, + AgenticReactAppearanceSettings, + AgenticReactProjectSettingsDefaults, + AgenticReactSettings, + AgenticReactSettingsBootstrap, + AgenticReactSettingsCapability, + AgenticReactSettingsClient, + AgenticReactSettingsError, + AgenticReactSettingsErrorCode, + AgenticReactSettingsRpcFailure, + AgenticReactSettingsRpcResult, + AgenticReactSettingsRpcSuccess, + AgenticReactSettingsSnapshot, + AgenticReactSettingsSource, + AgenticReactSettingsSources, + AgenticReactShortcutKey, + AgenticReactShortcutSettings, + AgenticReactToolkitConfig, + AgenticReactToolboxIconFilename, + AgenticReactToolboxIconMetadata, + AgenticReactToolboxIconMime, CustomClientFunction, CustomTool, JsonValue, diff --git a/packages/next/test/next-entry.test.mjs b/packages/next/test/next-entry.test.mjs index 9d2219d..e0ccef8 100644 --- a/packages/next/test/next-entry.test.mjs +++ b/packages/next/test/next-entry.test.mjs @@ -29,6 +29,7 @@ test('dev client entry is generated outside .next and restored when missing', as {}, { rootDir, + settingsRoot: path.join(rootDir, '.agentic-react-test'), server: { enabled: false }, }, ); diff --git a/packages/vite/README.md b/packages/vite/README.md index 48c39e9..863beab 100644 --- a/packages/vite/README.md +++ b/packages/vite/README.md @@ -17,6 +17,43 @@ export default defineConfig({ 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`. +## Settings Configuration + +The Vite adapter enables the toolbox Settings section because it hosts the local bridge used for persistence. User overrides are global to the machine under `~/.agentic-react/` and take precedence over project defaults and package defaults. + +Use `settingsRoot` only when you need an isolated settings directory for tests, demos, or temporary environments. Project defaults are configured with `toolkit.settings.shortcuts`, and the project default launcher icon is configured with `toolkit.iconUrl`. + +```ts +import { defineConfig } from 'vite'; +import AgenticReact from '@agentic-react/vite'; +import type { AgenticReactToolkitConfig } from '@agentic-react/vite'; + +const toolkit: AgenticReactToolkitConfig = { + iconUrl: '/agentic-react-logo.png', + settings: { + shortcuts: { + singleSelect: 'Ctrl+Shift+S', + multiSelect: 'Ctrl+Shift+M', + toggleToolbox: 'Ctrl+Shift+A', + done: 'Enter', + }, + }, +}; + +export default defineConfig({ + plugins: [ + AgenticReact({ + settingsRoot: '/tmp/agentic-react-settings', + toolkit, + }), + ], +}); +``` + +The Settings UI can change shortcuts and crop a custom toolbox icon. Reset removes the global override and falls back to the project default when present, otherwise to the package default. `Escape` is reserved for cancelling pending selection and cannot be assigned. + +Clicking a target captures a pending selection but does not copy it. Click Done, or press the configured Done shortcut, to copy and commit the pending selection; multiselect copies the complete pending set. + ## Tuning Modal Configuration Pass tuning modal options through `AgenticReact({ toolkit })`. The `toolkit` object uses the shared `ToolkitConfig` shape; `toolkit.tuningModal` accepts `classNames`, `styles`, and `tokens`. diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index e3f071f..191535c 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -1,5 +1,6 @@ import { createRequire } from 'node:module'; import path from 'node:path'; +import { createAgenticReactSettingsEngine } from '@agentic-react/core'; import { RuntimeBridgeServer } from '@agentic-react/core/bridge'; import { createStreamableHttpMcpHandler, @@ -12,9 +13,10 @@ import { import { generateCustomToolsScript } from '@agentic-react/core/shared/custom-tools-script'; import { BRIDGE_WS_PATH } from '@agentic-react/core/shared/protocol'; import type { + AgenticReactToolkitConfig, CustomTool, - ToolkitConfig, } from '@agentic-react/core/shared/types'; +import { registerSourceLookupHandler } from '@agentic-react/core/source-lookup'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { type ViteDevServer, normalizePath } from 'vite'; import type { Plugin, ResolvedConfig } from 'vite'; @@ -44,13 +46,18 @@ function instrumentViteDevServer( export interface AgenticReactOptions { customTools?: CustomTool[]; - toolkit?: ToolkitConfig; + settingsRoot?: string; + toolkit?: AgenticReactToolkitConfig; } function AgenticReact(options: AgenticReactOptions = {}): Plugin { let config: ResolvedConfig; const customTools = options.customTools || []; const toolkitConfig = options.toolkit || {}; + const settingsEngine = createAgenticReactSettingsEngine({ + projectToolkitConfig: toolkitConfig, + settingsRoot: options.settingsRoot, + }); return { name: 'agentic-react', @@ -62,6 +69,8 @@ function AgenticReact(options: AgenticReactOptions = {}): Plugin { if (viteDevServer.httpServer) { runtimeBridge.attach(viteDevServer.httpServer); } + registerSourceLookupHandler(runtimeBridge, viteDevServer.config.root); + settingsEngine.registerBridge(runtimeBridge); const createMcpServer = () => initMcpServer(runtimeBridge, viteDevServer.config.root, customTools); @@ -97,6 +106,7 @@ function AgenticReact(options: AgenticReactOptions = {}): Plugin { window.${__AGENTIC_REACT_CONFIG__} = ${JSON.stringify({ toolkit: toolkitConfig, sourceRoot: config.root, + settings: settingsEngine.getBootstrap(), })}; `; @@ -179,6 +189,26 @@ function toClientImportSpecifier( export default AgenticReact; export type { AgenticReactConfig, + AgenticReactAppearanceSettings, + AgenticReactProjectSettingsDefaults, + AgenticReactSettings, + AgenticReactSettingsBootstrap, + AgenticReactSettingsCapability, + AgenticReactSettingsClient, + AgenticReactSettingsError, + AgenticReactSettingsErrorCode, + AgenticReactSettingsRpcFailure, + AgenticReactSettingsRpcResult, + AgenticReactSettingsRpcSuccess, + AgenticReactSettingsSnapshot, + AgenticReactSettingsSource, + AgenticReactSettingsSources, + AgenticReactShortcutKey, + AgenticReactShortcutSettings, + AgenticReactToolkitConfig, + AgenticReactToolboxIconFilename, + AgenticReactToolboxIconMetadata, + AgenticReactToolboxIconMime, CustomClientFunction, CustomTool, JsonValue, diff --git a/packages/webpack/README.md b/packages/webpack/README.md index 3d958c8..9db08b1 100644 --- a/packages/webpack/README.md +++ b/packages/webpack/README.md @@ -15,6 +15,43 @@ export default (env, argv) => 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`. +## Settings Configuration + +The Webpack adapter enables the toolbox Settings section because it hosts the local bridge used for persistence. User overrides are global to the machine under `~/.agentic-react/` and take precedence over project defaults and package defaults. + +Use `settingsRoot` only when you need an isolated settings directory for tests, demos, or temporary environments. Project defaults are configured with `toolkit.settings.shortcuts`, and the project default launcher icon is configured with `toolkit.iconUrl`. + +```ts +import withAgenticReactWebpack from '@agentic-react/webpack'; +import type { AgenticReactToolkitConfig } from '@agentic-react/webpack'; + +const toolkit: AgenticReactToolkitConfig = { + iconUrl: '/agentic-react-logo.png', + settings: { + shortcuts: { + singleSelect: 'Ctrl+Shift+S', + multiSelect: 'Ctrl+Shift+M', + toggleToolbox: 'Ctrl+Shift+A', + done: 'Enter', + }, + }, +}; + +export default (env = {}, argv = {}) => + withAgenticReactWebpack( + config, + { mode: argv.mode || env.mode || 'development' }, + { + settingsRoot: '/tmp/agentic-react-settings', + toolkit, + }, + ); +``` + +The Settings UI can change shortcuts and crop a custom toolbox icon. Reset removes the global override and falls back to the project default when present, otherwise to the package default. `Escape` is reserved for cancelling pending selection and cannot be assigned. + +Clicking a target captures a pending selection but does not copy it. Click Done, or press the configured Done shortcut, to copy and commit the pending selection; multiselect copies the complete pending set. + ## Tuning Modal Configuration Pass tuning modal options as the third argument to `withAgenticReactWebpack`. The `toolkit` object uses the shared `ToolkitConfig` shape; `toolkit.tuningModal` accepts `classNames`, `styles`, and `tokens`. diff --git a/packages/webpack/package.json b/packages/webpack/package.json index cea0177..37e7488 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -6,7 +6,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "shx rm -rf dist && tsc" + "build": "shx rm -rf dist && tsc", + "test": "pnpm run build && node --test test/*.test.mjs" }, "keywords": [ "agentic-react", diff --git a/packages/webpack/src/index.ts b/packages/webpack/src/index.ts index 5116c34..e9a0037 100644 --- a/packages/webpack/src/index.ts +++ b/packages/webpack/src/index.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; +import { createAgenticReactSettingsEngine } from '@agentic-react/core'; import { RuntimeBridgeServer } from '@agentic-react/core/bridge'; import { createStreamableHttpMcpHandler, @@ -17,20 +18,34 @@ import { } from '@agentic-react/core/shared/custom-tools-script'; import { SOURCE_LOOKUP_PATH } from '@agentic-react/core/shared/protocol'; import type { + AgenticReactSettingsBootstrap, + AgenticReactToolkitConfig, CustomTool, - SelectionResolvedSource, - ToolkitConfig, } from '@agentic-react/core/shared/types'; +import { + findComponentSourceInProject, + registerSourceLookupHandler, +} from '@agentic-react/core/source-lookup'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; interface WebpackEnv { mode?: string; } +const resolveWebpackMode = ( + config: Record, + env: WebpackEnv, +): string | undefined => { + if (typeof env.mode === 'string') return env.mode; + if (typeof config.mode === 'string') return config.mode; + return process.env.NODE_ENV; +}; + export interface AgenticReactWebpackOptions { customTools?: CustomTool[]; rootDir?: string; - toolkit?: ToolkitConfig; + settingsRoot?: string; + toolkit?: AgenticReactToolkitConfig; } type WebpackDevServer = { @@ -61,7 +76,8 @@ const writeWebpackClientEntry = ( rootDir: string, generatedEntryName: string, customTools: CustomTool[], - toolkitConfig: ToolkitConfig, + toolkitConfig: AgenticReactToolkitConfig, + settingsBootstrap: AgenticReactSettingsBootstrap, ): string => { const coreDistPath = getCoreDistPath(); const generatedDirectory = generatedEntryName @@ -102,6 +118,7 @@ if (typeof window !== 'undefined') { ...(existingAgenticReactConfig.toolkit || {}), ...${JSON.stringify(toolkitConfig)}, }, + settings: existingAgenticReactConfig.settings || ${JSON.stringify(settingsBootstrap)}, }; if (!window[${__AGENTIC_REACT_BRIDGE_URL__}]) { @@ -153,135 +170,6 @@ const prependWebpackEntry = ( 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, @@ -330,8 +218,7 @@ export const withAgenticReactWebpack = ( env: WebpackEnv = {}, options: AgenticReactWebpackOptions = {}, ) => { - const isDevelopmentMode = env.mode ? env.mode === 'development' : true; - if (!isDevelopmentMode) { + if (resolveWebpackMode(config, env) !== 'development') { return config; } @@ -341,13 +228,20 @@ export const withAgenticReactWebpack = ( (typeof nextConfig.context === 'string' ? nextConfig.context : process.cwd()); + const settingsEngine = createAgenticReactSettingsEngine({ + projectToolkitConfig: options.toolkit || {}, + settingsRoot: options.settingsRoot, + }); const entryPath = writeWebpackClientEntry( rootDir, getGeneratedEntryName(nextConfig), options.customTools || [], options.toolkit || {}, + settingsEngine.getBootstrap(), ); const runtimeBridge = new RuntimeBridgeServer(); + registerSourceLookupHandler(runtimeBridge, rootDir); + settingsEngine.registerBridge(runtimeBridge); const createMcpServer = () => initMcpServer(runtimeBridge, rootDir, options.customTools || []); const mcpMiddlewares = [ @@ -395,6 +289,26 @@ export const withAgenticReactWebpack = ( export default withAgenticReactWebpack; export type { AgenticReactConfig, + AgenticReactAppearanceSettings, + AgenticReactProjectSettingsDefaults, + AgenticReactSettings, + AgenticReactSettingsBootstrap, + AgenticReactSettingsCapability, + AgenticReactSettingsClient, + AgenticReactSettingsError, + AgenticReactSettingsErrorCode, + AgenticReactSettingsRpcFailure, + AgenticReactSettingsRpcResult, + AgenticReactSettingsRpcSuccess, + AgenticReactSettingsSnapshot, + AgenticReactSettingsSource, + AgenticReactSettingsSources, + AgenticReactShortcutKey, + AgenticReactShortcutSettings, + AgenticReactToolkitConfig, + AgenticReactToolboxIconFilename, + AgenticReactToolboxIconMetadata, + AgenticReactToolboxIconMime, CustomClientFunction, CustomTool, JsonValue, diff --git a/packages/webpack/test/webpack-mode.test.mjs b/packages/webpack/test/webpack-mode.test.mjs new file mode 100644 index 0000000..2ff4700 --- /dev/null +++ b/packages/webpack/test/webpack-mode.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { withAgenticReactWebpack } from '../dist/index.js'; + +const withNodeEnv = (value, run) => { + const previous = process.env.NODE_ENV; + try { + if (value === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = value; + } + return run(); + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previous; + } + } +}; + +const createTestPaths = () => { + const rootDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'agentic-react-webpack-mode-'), + ); + return { + rootDir, + settingsRoot: path.join(rootDir, '.settings-test'), + generatedDirectory: path.join(rootDir, '.agentic-react-webpack'), + }; +}; + +const assertNotInjected = (config, env, nodeEnv) => { + const paths = createTestPaths(); + try { + const result = withNodeEnv(nodeEnv, () => + withAgenticReactWebpack(config, env, { + rootDir: paths.rootDir, + settingsRoot: paths.settingsRoot, + }), + ); + + assert.strictEqual(result, config); + assert.equal(fs.existsSync(paths.generatedDirectory), false); + assert.equal(fs.existsSync(paths.settingsRoot), false); + } finally { + fs.rmSync(paths.rootDir, { recursive: true, force: true }); + } +}; + +const assertInjected = (config, env, nodeEnv) => { + const paths = createTestPaths(); + try { + const result = withNodeEnv(nodeEnv, () => + withAgenticReactWebpack(config, env, { + rootDir: paths.rootDir, + settingsRoot: paths.settingsRoot, + }), + ); + + assert.notStrictEqual(result, config); + assert.ok(Array.isArray(result.entry)); + assert.equal(result.entry[1], config.entry); + assert.equal(fs.existsSync(result.entry[0]), true); + assert.equal(typeof result.devServer?.setupMiddlewares, 'function'); + assert.equal(typeof result.devServer?.onListening, 'function'); + } finally { + fs.rmSync(paths.rootDir, { recursive: true, force: true }); + } +}; + +test('config production mode stays untouched even when NODE_ENV is development', () => { + const config = { mode: 'production', entry: './src.js' }; + assertNotInjected(config, {}, 'development'); +}); + +test('missing mode stays untouched when NODE_ENV is missing', () => { + const config = { entry: './src.js' }; + assertNotInjected(config, {}, undefined); +}); + +test('an unknown explicit mode stays untouched', () => { + const config = { mode: 'development', entry: './src.js' }; + assertNotInjected(config, { mode: 'staging' }, 'development'); +}); + +test('env mode takes precedence over config mode and NODE_ENV', () => { + assertInjected( + { mode: 'production', entry: './src.js' }, + { mode: 'development' }, + 'production', + ); + + const config = { mode: 'development', entry: './src.js' }; + assertNotInjected(config, { mode: 'production' }, 'development'); +}); + +test('config development mode injects when env mode is absent', () => { + assertInjected( + { mode: 'development', entry: './src.js' }, + {}, + 'production', + ); +}); + +test('NODE_ENV development injects when higher-priority modes are absent', () => { + assertInjected({ entry: './src.js' }, {}, 'development'); +}); diff --git a/playground/agentic-react-next-playground/e2e/runtime.spec.js b/playground/agentic-react-next-playground/e2e/runtime.spec.js index d5e7af3..b9ded2e 100644 --- a/playground/agentic-react-next-playground/e2e/runtime.spec.js +++ b/playground/agentic-react-next-playground/e2e/runtime.spec.js @@ -25,6 +25,20 @@ const parseToolResponse = (result) => { return JSON.parse(textContent.text); }; +const installClipboardStub = async (page) => { + await page.evaluate(() => { + window.__AGENTIC_REACT_TEST_CLIPBOARD__ = null; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: async (text) => { + window.__AGENTIC_REACT_TEST_CLIPBOARD__ = String(text); + }, + }, + }); + }); +}; + test('next playground injects runtime globals', async ({ page }) => { await page.goto('/'); await page.waitForFunction(() => window.__AGENTIC_REACT__); @@ -68,8 +82,16 @@ test('next playground injects runtime globals', async ({ page }) => { }); test('next playground MCP tools return expected outcomes', async ({ page }) => { + const legacySourceLookupRequests = []; + page.on('request', (request) => { + if (new URL(request.url()).pathname === '/__agentic_react_source') { + legacySourceLookupRequests.push(request.url()); + } + }); + await page.goto('/'); await page.waitForFunction(() => window.__AGENTIC_REACT__); + await installClipboardStub(page); const { client, transport } = await createMcpClient(); try { @@ -117,9 +139,39 @@ test('next playground MCP tools return expected outcomes', async ({ page }) => { expect(enableSelection.success).toBe(true); expect(enableSelection.enabled).toBe(true); + const contextBeforeSelectionRaw = await client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 3, + maxFiles: 3, + }, + }); + const contextBeforeSelection = parseToolResponse(contextBeforeSelectionRaw); + expect(contextBeforeSelection.success).toBe(false); + expect(contextBeforeSelection.context).toBeNull(); + await page.locator('#next-copy-target').click(); - await page.waitForFunction(() => - window.__AGENTIC_REACT__?.getLastSelectionContext(), + await expect( + page.locator('[data-agentic-react-selected-label="true"]'), + ).toBeVisible(); + const pendingContextRaw = await client.callTool({ + name: 'get-last-selection-context', + arguments: { + includeSourceSnippets: true, + contextLines: 3, + maxFiles: 3, + }, + }); + const pendingContext = parseToolResponse(pendingContextRaw); + expect(pendingContext.success).toBe(false); + expect(pendingContext.context).toBeNull(); + + await page.getByRole('button', { name: 'Done' }).click(); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '#next-copy-target', ); const contextRaw = await client.callTool({ name: 'get-last-selection-context', @@ -140,6 +192,7 @@ test('next playground MCP tools return expected outcomes', async ({ page }) => { const copyResponse = parseToolResponse(copyRaw); expect(copyResponse.success).toBe(true); expect(copyResponse.copied).toBe(true); + expect(legacySourceLookupRequests).toEqual([]); } finally { await transport.close(); } 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 index 3d1a896..4f8474a 100644 --- a/playground/agentic-react-nx-module-federation-playground/e2e/runtime.spec.js +++ b/playground/agentic-react-nx-module-federation-playground/e2e/runtime.spec.js @@ -56,6 +56,42 @@ const getToolText = (result) => { return textContent.text; }; +const installClipboardStub = async (page) => { + await page.evaluate(() => { + window.__AGENTIC_REACT_TEST_CLIPBOARD__ = null; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: async (text) => { + window.__AGENTIC_REACT_TEST_CLIPBOARD__ = String(text); + }, + }, + }); + }); +}; + +const waitForPendingSelectionOverlay = async (page) => { + await page.waitForFunction(() => + Array.from( + document.querySelectorAll('[data-agentic-react-selected-label="true"]'), + ).some((element) => { + const parentElement = element.parentElement; + if (!parentElement) { + return false; + } + + const style = window.getComputedStyle(element); + const parentStyle = window.getComputedStyle(parentElement); + return ( + style.visibility !== 'hidden' && + parentStyle.display !== 'none' && + parentStyle.visibility !== 'hidden' && + element.textContent.trim().length > 0 + ); + }), + ); +}; + const expectProfileMemberCardSource = (context) => { expect(context.resolvedSources).toContainEqual( expect.objectContaining({ @@ -82,20 +118,26 @@ const selectRemoteProfileMember = async (page, request) => { await page.goto('/profile'); await expect(page.getByText('Federated team profile routes.')).toBeVisible(); await page.waitForFunction(() => window.__AGENTIC_REACT__); + await installClipboardStub(page); await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + const contextBeforeSelection = await page.evaluate(() => + window.__AGENTIC_REACT__.getLastSelectionContext(), + ); + expect(contextBeforeSelection).toBeNull(); await page.locator('#profile-member-sam-rivera').click(); - const didCapture = await page.evaluate(() => - Boolean(window.__AGENTIC_REACT__?.getLastSelectionContext()), + await waitForPendingSelectionOverlay(page); + const pendingCommittedContext = await page.evaluate(() => + window.__AGENTIC_REACT__.getLastSelectionContext(), ); - if (!didCapture) { - await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); - await page.locator('#profile-member-sam-rivera').click(); - } + expect(pendingCommittedContext).toBeNull(); - await page.waitForFunction(() => - window.__AGENTIC_REACT__?.getLastSelectionContext(), + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '#profile-member-sam-rivera', ); }; @@ -241,11 +283,25 @@ test('nx selection attributes external package components to local usage source' await page.goto('/profile'); await expect(page.getByText('Federated team profile routes.')).toBeVisible(); await page.waitForFunction(() => window.__AGENTIC_REACT__); + await installClipboardStub(page); await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + const contextBeforeSelection = await page.evaluate(() => + window.__AGENTIC_REACT__.getLastSelectionContext(), + ); + expect(contextBeforeSelection).toBeNull(); await page.locator('#profile-external-component-probe').click(); - await page.waitForFunction(() => - window.__AGENTIC_REACT__?.getLastSelectionContext(), + await waitForPendingSelectionOverlay(page); + const pendingCommittedContext = await page.evaluate(() => + window.__AGENTIC_REACT__.getLastSelectionContext(), + ); + expect(pendingCommittedContext).toBeNull(); + + await page.getByRole('button', { name: 'Done' }).click(); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '#profile-external-component-probe', ); const { client, transport } = await createMcpClient(); diff --git a/playground/agentic-react-vite-playground/e2e/global-setup.js b/playground/agentic-react-vite-playground/e2e/global-setup.js new file mode 100644 index 0000000..f6c2800 --- /dev/null +++ b/playground/agentic-react-vite-playground/e2e/global-setup.js @@ -0,0 +1,15 @@ +import { mkdirSync, rmSync } from 'node:fs'; + +export default function globalSetup() { + const settingsRoot = process.env.AGENTIC_REACT_E2E_SETTINGS_ROOT; + if (!settingsRoot) { + throw new Error('The Vite E2E settings root must be isolated.'); + } + + rmSync(settingsRoot, { force: true, recursive: true }); + mkdirSync(settingsRoot, { recursive: true }); + + return () => { + rmSync(settingsRoot, { force: true, recursive: true }); + }; +} diff --git a/playground/agentic-react-vite-playground/e2e/selection-context.spec.js b/playground/agentic-react-vite-playground/e2e/selection-context.spec.js index e206fed..b09a1ac 100644 --- a/playground/agentic-react-vite-playground/e2e/selection-context.spec.js +++ b/playground/agentic-react-vite-playground/e2e/selection-context.spec.js @@ -1,8 +1,25 @@ import { expect, test } from '@playwright/test'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { fileURLToPath } from 'node:url'; const MCP_SERVER_URL = 'http://127.0.0.1:51423/mcp'; +const TOOLBOX_ICON_FIXTURE = fileURLToPath( + new URL( + '../../../packages/core/src/assets/agentic-react-toolkit-logo.png', + import.meta.url, + ), +); +const ACTIVATION_EVENTS = [ + 'pointerdown', + 'pointerup', + 'mousedown', + 'mouseup', + 'click', + 'touchstart', + 'touchend', + 'contextmenu', +]; const createMcpClient = async () => { const client = new Client({ @@ -37,27 +54,87 @@ const selectProfileEmailField = async (page) => { 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()), - ); - } + await expect(page.locator('[data-agentic-react-selected="true"]')).toBeVisible(); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '#profile-field-email', + ); }; const getToolkitRoot = (page) => page.locator('[data-agentic-react-toolkit="true"]'); +const setClipboardMarker = async (page, marker) => { + await page.evaluate((value) => navigator.clipboard.writeText(value), marker); +}; + +const installHostEventRecorder = async (page) => { + await page.evaluate((eventNames) => { + window.__AGENTIC_REACT_E2E_HOST_EVENTS__ = []; + const popover = document.createElement('div'); + popover.id = 'agentic-react-e2e-transient-popover'; + popover.textContent = 'Transient host UI'; + document.body.appendChild(popover); + + for (const eventName of eventNames) { + document.addEventListener(eventName, (event) => { + window.__AGENTIC_REACT_E2E_HOST_EVENTS__.push(event.type); + popover.remove(); + }); + } + }, ACTIVATION_EVENTS); +}; + +const expectHostDidNotReceiveActivation = async (page) => { + await expect(page.locator('#agentic-react-e2e-transient-popover')).toBeVisible(); + expect( + await page.evaluate(() => window.__AGENTIC_REACT_E2E_HOST_EVENTS__), + ).toEqual([]); +}; + +const dispatchActivationEvents = async (locator) => { + await locator.evaluate((element, eventNames) => { + for (const eventName of eventNames) { + const EventConstructor = eventName.startsWith('pointer') + ? PointerEvent + : eventName.startsWith('touch') + ? Event + : MouseEvent; + element.dispatchEvent( + new EventConstructor(eventName, { + bubbles: true, + cancelable: true, + composed: true, + }), + ); + } + }, ACTIVATION_EVENTS); +}; + +const openSettings = async (page) => { + const toolkitRoot = getToolkitRoot(page); + await openToolkitPanel(page); + const settingsButton = toolkitRoot.getByRole('button', { + name: 'Settings', + exact: true, + }); + if ((await settingsButton.getAttribute('aria-expanded')) !== 'true') { + await settingsButton.click(); + } + await expect( + toolkitRoot.locator('[data-agentic-react-settings="true"]'), + ).toBeVisible(); +}; + +const getShortcutRow = (page, label) => + getToolkitRoot(page) + .locator('[data-agentic-react-settings="true"]') + .getByText(label, { exact: true }) + .locator('..') + .locator('..'); + const openToolkitPanel = async (page) => { const toolkitRoot = getToolkitRoot(page); await toolkitRoot.waitFor(); @@ -82,20 +159,26 @@ const openToolkitPanel = async (page) => { await expect(selectButton).toBeVisible(); }; -const captureWithToolkitUi = async (page, selector) => { +const capturePendingWithToolkitUi = 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 expect(page.locator('[data-agentic-react-selected="true"]')).toBeVisible(); + await expect( + toolkitRoot.getByText('Captured', { exact: false }), + ).toBeVisible(); +}; + +const captureWithToolkitUi = async (page, selector) => { + await capturePendingWithToolkitUi(page, selector); + await page.keyboard.press('Enter'); 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 ({ @@ -136,17 +219,31 @@ test('selecting a profile field captures its React source context', async ({ ); }); -test('toolkit select copies the selected context automatically', async ({ +test('toolkit single select waits for Done before copying', async ({ page, }) => { await page.goto('/profile/1'); await page.getByRole('button', { name: 'Edit Profile', exact: true }).click(); + await setClipboardMarker(page, 'single-select-not-committed'); - await captureWithToolkitUi(page, '#profile-field-email'); + await capturePendingWithToolkitUi(page, '#profile-field-email'); - const clipboardText = await page.evaluate(() => - navigator.clipboard.readText(), + const toolkitRoot = getToolkitRoot(page); + const doneButton = toolkitRoot.getByRole('button', { + name: 'Done', + exact: true, + }); + await expect(doneButton).toBeVisible(); + await expect(doneButton).toBeEnabled(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + 'single-select-not-committed', ); + + await doneButton.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toContain('selector: #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( @@ -157,6 +254,258 @@ test('toolkit select copies the selected context automatically', async ({ expect(clipboardText).toContain( '/playground/agentic-react-vite-playground/src/components/UserProfile/ProfileField.jsx', ); + await expect(doneButton).toBeHidden(); +}); + +test('Enter commits a pending single selection', async ({ page }) => { + await page.goto('/profile/1'); + await page.getByRole('button', { name: 'Edit Profile', exact: true }).click(); + await setClipboardMarker(page, 'enter-not-committed'); + + await capturePendingWithToolkitUi(page, '#profile-field-email'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + 'enter-not-committed', + ); + + await page.keyboard.press('Enter'); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toContain('selector: #profile-field-email'); + await expect( + getToolkitRoot(page).getByRole('button', { name: 'Done', exact: true }), + ).toBeHidden(); +}); + +test('Escape cancels active and pending selections without reaching the host', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + await setClipboardMarker(page, 'escape-must-not-copy'); + await page.evaluate(() => { + window.__AGENTIC_REACT_E2E_ESCAPE_EVENTS__ = []; + for (const eventName of ['keydown', 'keyup']) { + document.addEventListener( + eventName, + (event) => { + if (event.key === 'Escape') { + window.__AGENTIC_REACT_E2E_ESCAPE_EVENTS__.push(event.type); + } + }, + true, + ); + } + }); + + const toolkitRoot = getToolkitRoot(page); + await openToolkitPanel(page); + await toolkitRoot.getByRole('button', { name: 'Select', exact: true }).click(); + await page.keyboard.press('Escape'); + await expect(toolkitRoot).toContainText('Selection cancelled'); + await expect( + toolkitRoot.getByRole('button', { name: 'Select', exact: true }), + ).toBeVisible(); + await expect(page.locator('[data-agentic-react-selected="true"]')).toBeHidden(); + + await toolkitRoot.getByRole('button', { name: 'Select', exact: true }).click(); + await page.locator('#profile-display-email-value').click(); + await expect(page.locator('[data-agentic-react-selected="true"]')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('[data-agentic-react-selected="true"]')).toBeHidden(); + expect( + await page.evaluate(() => window.__AGENTIC_REACT__.getLastSelectionContext()), + ).toBeNull(); + + await toolkitRoot + .getByRole('button', { name: 'Multiselect', exact: true }) + .click(); + await page.locator('#profile-display-email-value').click(); + await page.locator('#profile-header-occupation-value').click(); + await expect( + page.locator('[data-agentic-react-multi-selected="true"]'), + ).toHaveCount(2); + await page.keyboard.press('Escape'); + await expect( + page.locator('[data-agentic-react-multi-selected="true"]'), + ).toHaveCount(0); + await expect(toolkitRoot).toContainText('Selection cancelled'); + await expect( + toolkitRoot.getByRole('button', { name: 'Select', exact: true }), + ).toBeVisible(); + + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + 'escape-must-not-copy', + ); + expect( + await page.evaluate(() => window.__AGENTIC_REACT_E2E_ESCAPE_EVENTS__), + ).toEqual([]); +}); + +test('toolbox, selection controls, and tuning UI do not activate the host app', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__); + const toolkitRoot = getToolkitRoot(page); + await openToolkitPanel(page); + await toolkitRoot.getByRole('button', { name: 'Select', exact: true }).click(); + await page.locator('#profile-display-email-value').click(); + const selectedActions = page.locator( + '[data-agentic-react-selected-actions="true"]', + ); + await selectedActions + .getByRole('button', { name: 'Adjust selection', exact: true }) + .click({ force: true }); + const tuningModal = page.locator('[data-agentic-react-tuning-modal="true"]'); + await expect(tuningModal).toBeVisible(); + + await installHostEventRecorder(page); + await dispatchActivationEvents( + toolkitRoot.locator('[data-agentic-react-launcher="true"]'), + ); + await dispatchActivationEvents( + toolkitRoot.getByRole('button', { name: 'Settings', exact: true }), + ); + await dispatchActivationEvents(selectedActions); + await dispatchActivationEvents(tuningModal); + + await expectHostDidNotReceiveActivation(page); +}); + +test('settings record, reject duplicates, persist, and reset one shortcut', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__?.settings); + await openSettings(page); + + const toolkitRoot = getToolkitRoot(page); + const singleRow = getShortcutRow(page, 'Single select'); + await expect(singleRow).toContainText('Ctrl+Alt+Shift+S'); + await expect(singleRow).toContainText('Project configuration'); + await expect(getShortcutRow(page, 'Multi select')).toContainText( + 'Ctrl+Alt+Shift+M', + ); + await expect(getShortcutRow(page, 'Toggle toolbox')).toContainText( + 'Ctrl+Alt+Shift+A', + ); + await expect(getShortcutRow(page, 'Done')).toContainText('Enter'); + await expect(toolkitRoot).toContainText('Cancel selection'); + await expect(toolkitRoot).toContainText('Escape'); + + await singleRow.getByRole('button').first().click(); + await page.keyboard.press('Control+Alt+Shift+Y'); + await expect(toolkitRoot).toContainText('Saved Ctrl+Alt+Shift+Y.'); + await expect(getShortcutRow(page, 'Single select')).toContainText( + 'Global override', + ); + + const updatedSingleRow = getShortcutRow(page, 'Single select'); + await updatedSingleRow.getByRole('button').first().click(); + await page.keyboard.press('Control+Alt+Shift+M'); + await expect(toolkitRoot).toContainText( + 'already assigned to Multi select', + ); + await page.keyboard.press('Escape'); + await expect(toolkitRoot).toContainText('Shortcut recording cancelled.'); + + await page.reload(); + await page.waitForFunction(() => window.__AGENTIC_REACT__?.settings); + await openSettings(page); + await expect(getShortcutRow(page, 'Single select')).toContainText( + 'Ctrl+Alt+Shift+Y', + ); + await expect(getShortcutRow(page, 'Single select')).toContainText( + 'Global override', + ); + + await page.keyboard.press('Control+Alt+Shift+Y'); + await expect(getToolkitRoot(page)).toContainText('Selection mode enabled.'); + await page.keyboard.press('Escape'); + + const persistedSingleRow = getShortcutRow(page, 'Single select'); + await persistedSingleRow.getByRole('button', { name: 'Reset' }).click(); + await expect(getToolkitRoot(page)).toContainText( + 'Reset Single select shortcut.', + ); + await expect(getShortcutRow(page, 'Single select')).toContainText( + 'Ctrl+Alt+Shift+S', + ); + await expect(getShortcutRow(page, 'Single select')).toContainText( + 'Project configuration', + ); +}); + +test('toolbox icon crop persists globally without activating the host and resets to project icon', async ({ + page, +}) => { + await page.goto('/profile/1'); + await page.waitForFunction(() => window.__AGENTIC_REACT__?.settings); + await openSettings(page); + const toolkitRoot = getToolkitRoot(page); + await expect( + toolkitRoot.getByText('Toolbox icon', { exact: true }).locator('..'), + ).toContainText('Project configuration'); + + await installHostEventRecorder(page); + await toolkitRoot + .locator('input[type="file"]') + .setInputFiles(TOOLBOX_ICON_FIXTURE); + const cropDialog = page.getByRole('dialog', { name: 'Crop toolbox icon' }); + await expect(cropDialog).toBeVisible(); + await dispatchActivationEvents(cropDialog); + await expectHostDidNotReceiveActivation(page); + + const zoom = cropDialog.getByLabel('Zoom icon crop'); + await zoom.focus(); + await zoom.press('ArrowRight'); + await cropDialog.getByRole('button', { name: 'Rotate right' }).click(); + await cropDialog.getByRole('button', { name: 'Apply' }).click(); + await expect(cropDialog).toBeHidden(); + await expectHostDidNotReceiveActivation(page); + await expect(toolkitRoot).toContainText('Updated toolbox icon.'); + await expect + .poll(() => + toolkitRoot + .locator('[data-agentic-react-launcher="true"] img') + .getAttribute('src'), + ) + .toMatch(/^data:image\/(webp|png);base64,/); + await expect( + toolkitRoot.getByText('Toolbox icon', { exact: true }).locator('..'), + ).toContainText('Global override'); + + await page.reload(); + await page.waitForFunction(() => window.__AGENTIC_REACT__?.settings); + await openSettings(page); + const reloadedToolkitRoot = getToolkitRoot(page); + await expect + .poll(() => + reloadedToolkitRoot + .locator('[data-agentic-react-launcher="true"] img') + .getAttribute('src'), + ) + .toMatch(/^data:image\/(webp|png);base64,/); + await expect( + reloadedToolkitRoot + .getByText('Toolbox icon', { exact: true }) + .locator('..'), + ).toContainText('Global override'); + + await reloadedToolkitRoot.getByRole('button', { name: 'Reset' }).last().click(); + await expect(reloadedToolkitRoot).toContainText('Reset toolbox icon.'); + await expect + .poll(() => + reloadedToolkitRoot + .locator('[data-agentic-react-launcher="true"] img') + .getAttribute('src'), + ) + .toBe('/agentic-react-logo.png'); + await expect( + reloadedToolkitRoot + .getByText('Toolbox icon', { exact: true }) + .locator('..'), + ).toContainText('Project configuration'); }); test('toolkit multiselect appends selections and copies all on done', async ({ @@ -198,23 +547,18 @@ test('toolkit multiselect appends selections and copies all on done', async ({ 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 setClipboardMarker(page, 'multi-select-not-committed'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + 'multi-select-not-committed', + ); await expect(clearAllButton).toBeEnabled(); await clearAllButton.click(); @@ -225,17 +569,10 @@ test('toolkit multiselect appends selections and copies all on done', async ({ 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 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); @@ -483,9 +820,7 @@ test('toolkit tuning exposes layout controls for container elements', async ({ 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 expect(page.locator('[data-agentic-react-selected="true"]')).toBeVisible(); await page .locator('[data-agentic-react-selected="true"]') @@ -560,9 +895,7 @@ test('toolkit tuning modal extensions can wrap and add modal content', async ({ 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 expect(page.locator('[data-agentic-react-selected="true"]')).toBeVisible(); await page .locator('[data-agentic-react-selected="true"]') @@ -624,6 +957,8 @@ test('selecting an element without an id still returns a usable fallback selecto await page.evaluate(() => window.__AGENTIC_REACT__.enterSelectionMode()); await page.locator('label', { hasText: 'Email' }).first().click(); + await expect(page.locator('[data-agentic-react-selected="true"]')).toBeVisible(); + await page.keyboard.press('Enter'); const context = await page.waitForFunction(() => window.__AGENTIC_REACT__?.getLastSelectionContext(), @@ -924,6 +1259,15 @@ test('MCP built-in tooling endpoints all return expected outcomes', async ({ await expect(page.locator('#profile-field-email')).toBeVisible(); await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); await page.locator('#profile-field-email').click(); + await expect( + page.locator('[data-agentic-react-selected="true"]'), + ).toBeVisible(); + expect( + await page.evaluate(() => + window.__AGENTIC_REACT__.getLastSelectionContext(), + ), + ).toBeNull(); + await page.keyboard.press('Enter'); await page.waitForFunction( () => window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === diff --git a/playground/agentic-react-vite-playground/playwright.config.js b/playground/agentic-react-vite-playground/playwright.config.js index 9af2921..380833f 100644 --- a/playground/agentic-react-vite-playground/playwright.config.js +++ b/playground/agentic-react-vite-playground/playwright.config.js @@ -1,7 +1,16 @@ import { defineConfig, devices } from '@playwright/test'; +import { mkdtempSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const settingsRoot = mkdtempSync( + path.join(os.tmpdir(), 'agentic-react-vite-e2e-settings-'), +); +process.env.AGENTIC_REACT_E2E_SETTINGS_ROOT = settingsRoot; export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.js', timeout: 30000, expect: { timeout: 5000, @@ -16,6 +25,9 @@ export default defineConfig({ url: 'http://127.0.0.1:51423', reuseExistingServer: false, timeout: 30000, + env: { + AGENTIC_REACT_E2E_SETTINGS_ROOT: settingsRoot, + }, }, projects: [ { diff --git a/playground/agentic-react-vite-playground/vite.config.js b/playground/agentic-react-vite-playground/vite.config.js index a3d8fb0..6d35fec 100644 --- a/playground/agentic-react-vite-playground/vite.config.js +++ b/playground/agentic-react-vite-playground/vite.config.js @@ -9,6 +9,7 @@ export default defineConfig({ plugins: [ react(), AgenticReact({ + settingsRoot: process.env.AGENTIC_REACT_E2E_SETTINGS_ROOT, customTools: [ { name: 'log1', @@ -21,6 +22,14 @@ export default defineConfig({ ], toolkit: { iconUrl: '/agentic-react-logo.png', + settings: { + shortcuts: { + singleSelect: 'Ctrl+Alt+Shift+S', + multiSelect: 'Ctrl+Alt+Shift+M', + toggleToolbox: 'Ctrl+Alt+Shift+A', + done: 'Enter', + }, + }, tuningModal: { classNames: { surface: 'vite-playground-tuning-surface', 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 deleted file mode 100644 index d780609..0000000 --- a/playground/agentic-react-webpack-playground/.agentic-react-webpack/client-entry.mjs +++ /dev/null @@ -1,47 +0,0 @@ - -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 index 6fc4d94..33e79c7 100644 --- a/playground/agentic-react-webpack-playground/e2e/runtime.spec.js +++ b/playground/agentic-react-webpack-playground/e2e/runtime.spec.js @@ -24,6 +24,20 @@ const parseToolResponse = (result) => { return JSON.parse(textContent.text); }; +const installClipboardStub = async (page) => { + await page.evaluate(() => { + window.__AGENTIC_REACT_TEST_CLIPBOARD__ = null; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: async (text) => { + window.__AGENTIC_REACT_TEST_CLIPBOARD__ = String(text); + }, + }, + }); + }); +}; + test('webpack playground injects runtime globals', async ({ page }) => { await page.goto('/'); await page.waitForFunction(() => window.__AGENTIC_REACT__); @@ -148,8 +162,13 @@ test('webpack selection captures the primary CTA as userland AppContent source', }) => { await page.goto('/'); await page.waitForFunction(() => window.__AGENTIC_REACT__); + await installClipboardStub(page); await page.evaluate(() => window.__AGENTIC_REACT__.setSelectionMode(true)); + const contextBeforeSelection = await page.evaluate(() => + window.__AGENTIC_REACT__.getLastSelectionContext(), + ); + expect(contextBeforeSelection).toBeNull(); const primaryCta = page.locator('.primary-cta'); await expect(primaryCta).toBeVisible(); @@ -169,11 +188,6 @@ test('webpack selection captures the primary CTA as userland AppContent source', ); 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"]', ); @@ -183,6 +197,17 @@ test('webpack selection captures the primary CTA as userland AppContent source', expect(selectedLabelBox.y + selectedLabelBox.height).toBeLessThanOrEqual( ctaBox.y, ); + const pendingCommittedContext = await page.evaluate(() => + window.__AGENTIC_REACT__.getLastSelectionContext(), + ); + expect(pendingCommittedContext).toBeNull(); + + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => + window.__AGENTIC_REACT__?.getLastSelectionContext()?.selector === + '.hero > .hero-copy > .primary-cta', + ); const runtimeContext = await page.evaluate(() => window.__AGENTIC_REACT__.getLastSelectionContext(), diff --git a/scripts/release-smoke-test.mjs b/scripts/release-smoke-test.mjs index db1b471..c2e0949 100644 --- a/scripts/release-smoke-test.mjs +++ b/scripts/release-smoke-test.mjs @@ -48,7 +48,21 @@ const extractRunCommand = (stepName) => { const start = workflow.indexOf(marker); assert.notEqual(start, -1, `release workflow should include ${stepName}`); - const next = workflow.indexOf('\n - name:', start + marker.length); + const lineStart = workflow.lastIndexOf('\n', start) + 1; + const indentation = workflow.slice(lineStart, start); + const nextStep = workflow.indexOf( + `\n${indentation}- name:`, + start + marker.length, + ); + const remainingWorkflow = workflow.slice(start + marker.length); + const shallowerBoundary = remainingWorkflow.match( + new RegExp(`\\n {0,${indentation.length - 1}}\\S`), + ); + const nextSection = shallowerBoundary + ? start + marker.length + shallowerBoundary.index + : -1; + const boundaries = [nextStep, nextSection].filter((index) => index !== -1); + const next = boundaries.length > 0 ? Math.min(...boundaries) : -1; const step = workflow.slice(start, next === -1 ? undefined : next); const scalarRun = step.match(/\n run: ([^\n]+)/); @@ -104,6 +118,11 @@ const makeFakePnpm = (workspace, name) => { `#!/bin/sh echo "$@" >> "$PNPM_LOG" +if [ "$1" = "changeset" ] && [ "$2" = "status" ] && [ "$3" = "--output" ]; then + printf '{"releases":[{"name":"@agentic-react/core","newVersion":"1.0.0"}]}\n' > "$4" + exit 0 +fi + if [ "$1" = "run" ] && [ "$2" = "version-packages" ]; then printf "versioned\\n" > versioned.txt exit 0 @@ -157,6 +176,60 @@ const runVersionBlock = (repo, command, fakePnpm) => { }); }; +const runShellBlock = (repo, command, env = {}) => { + run('bash', ['-c', command], { + cwd: repo, + env, + }); +}; + +const commitAndPush = (repo, message) => { + run('git', ['add', '.'], { cwd: repo }); + run('git', ['commit', '-m', message], { cwd: repo }); + run('git', ['push', 'origin', 'HEAD:main'], { cwd: repo }); +}; + +const commitReleaseMarker = (repo) => { + const sourceSha = run('git', ['rev-parse', 'HEAD'], { cwd: repo }); + fs.writeFileSync( + path.join(repo, '.changeset/release-pending.json'), + `${JSON.stringify({ sourceSha }, undefined, 2)}\n`, + ); + run('git', ['add', '.changeset/release-pending.json'], { cwd: repo }); + run( + 'git', + [ + '-c', + 'user.name=github-actions[bot]', + '-c', + 'user.email=41898282+github-actions[bot]@users.noreply.github.com', + 'commit', + '-m', + 'chore(release): version packages', + ], + { cwd: repo }, + ); + run('git', ['push', 'origin', 'HEAD:main'], { cwd: repo }); +}; + +const inspectReleaseState = (workspace, fixture, name) => { + const outputPath = path.join(workspace, `${name}-output.txt`); + const summaryPath = path.join(workspace, `${name}-summary.md`); + + runShellBlock(fixture.repo, extractRunCommand('Inspect release state'), { + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: summaryPath, + }); + + return Object.fromEntries( + fs + .readFileSync(outputPath, 'utf8') + .trim() + .split('\n') + .map((line) => line.split('=')), + ); +}; + const list = (value) => value.split('\n').filter(Boolean).sort(); const runGitHubReleaseBlock = ( @@ -178,14 +251,29 @@ const runGitHubReleaseBlock = ( const testWorkflowContract = () => { assertContains(workflow, 'branches: [main]', 'release trigger'); + assert.ok( + !workflow.includes('workflow_dispatch:'), + 'release workflow should not be manually dispatchable from dev', + ); assertContains(workflow, 'fetch-depth: 0', 'release checkout'); + assertContains( + workflow, + "if: github.ref == 'refs/heads/main' && needs.preflight.outputs.release-required == 'true'", + 'release preflight gate', + ); const steps = [ 'Version packages from changesets', + 'Release smoke test', 'Build packages', + 'Test', + 'Commit version packages', 'Publish packages', + 'Ensure package publication succeeded', + 'Reconcile release tags', 'Push release tags', 'Create GitHub releases', + 'Clear recovery state', ]; let previous = -1; @@ -196,16 +284,51 @@ const testWorkflowContract = () => { } assertContains( - extractRunCommand('Version packages from changesets'), + extractRunCommand('Inspect release state'), 'find .changeset -maxdepth 1 -name "*.md" ! -name "README.md" | grep -q .', - 'changeset version gate', + 'changeset preflight gate', + ); + assertContains( + extractRunCommand('Version packages from changesets'), + '> .changeset/release-pending.json', + 'durable recovery marker', ); assertContains( extractRunCommand('Version packages from changesets'), + 'git add -A', + 'release changes staged before validation', + ); + assertContains( + extractRunCommand('Validate recovery state'), + 'expected_tree="$(git write-tree)"', + 'deterministic recovery verification', + ); + assert.ok( + !extractRunCommand('Version packages from changesets').includes( + 'git push origin HEAD:main', + ), + 'versioning should not push before validation', + ); + assertContains( + extractRunCommand('Commit version packages'), 'git push origin HEAD:main', - 'version package push', + 'validated version package push', + ); + assert.ok( + !extractRunCommand('Commit version packages').includes('git add -A'), + 'validation artifacts should not enter the version commit', ); assert.equal(extractRunCommand('Publish packages'), 'pnpm run release'); + assertContains( + workflow, + 'continue-on-error: true', + 'partial publication recovery', + ); + assertContains( + extractRunCommand('Reconcile release tags'), + "readFileSync('.changeset/release-plan.json', 'utf8')", + 'release plan tag recovery', + ); assert.equal( extractRunCommand('Push release tags'), 'git push origin --tags', @@ -219,49 +342,238 @@ const testWorkflowContract = () => { ); assertContains(releaseBlock, 'gh release view "$tag"', 'release idempotency'); assertContains(releaseBlock, 'gh release create "$tag"', 'release creation'); + assertContains( + extractRunCommand('Clear recovery state'), + 'git rm .changeset/release-pending.json', + 'recovery cleanup', + ); }; -const testPendingChangesetDetection = (workspace) => { - const versionBlock = extractRunCommand('Version packages from changesets'); - +const testReleasePreflight = (workspace) => { const noChangesRepo = makeGitFixture(workspace, 'no-pending-changesets'); - const noChangesPnpm = makeFakePnpm(workspace, 'no-pending-changesets'); - runVersionBlock(noChangesRepo.repo, versionBlock, noChangesPnpm); - - assert.equal( - fs.existsSync(noChangesPnpm.logPath), - false, - 'changeset README alone should not run version-packages', - ); - assert.equal( - run('git', ['log', '-1', '--pretty=%s'], { cwd: noChangesRepo.repo }), - 'initial fixture', - 'no pending changesets should not create a version commit', + const noChanges = inspectReleaseState( + workspace, + noChangesRepo, + 'no-pending-changesets', ); + assert.equal(noChanges['release-required'], 'false'); + assert.equal(noChanges['release-mode'], 'none'); const pendingRepo = makeGitFixture(workspace, 'pending-changesets'); - const pendingPnpm = makeFakePnpm(workspace, 'pending-changesets'); fs.writeFileSync( path.join(pendingRepo.repo, '.changeset/release-core.md'), '---\n"@agentic-react/core": patch\n---\n\nRelease core.\n', ); + commitAndPush(pendingRepo.repo, 'add core changeset'); + const pending = inspectReleaseState( + workspace, + pendingRepo, + 'pending-changesets', + ); + assert.equal(pending['release-required'], 'true'); + assert.equal(pending['release-mode'], 'version'); + + const recoveryRepo = makeGitFixture(workspace, 'release-recovery'); + fs.writeFileSync( + path.join(recoveryRepo.repo, '.changeset/release-core.md'), + '---\n"@agentic-react/core": patch\n---\n\nRelease core.\n', + ); + commitAndPush(recoveryRepo.repo, 'add recovery changeset'); + commitReleaseMarker(recoveryRepo.repo); + const recoveryCommit = run('git', ['rev-parse', 'HEAD'], { + cwd: recoveryRepo.repo, + }); + const recovery = inspectReleaseState( + workspace, + recoveryRepo, + 'release-recovery', + ); + assert.equal(recovery['release-required'], 'true'); + assert.equal(recovery['release-mode'], 'recovery'); + assert.equal(recovery['release-sha'], recoveryCommit); + + const untrustedRepo = makeGitFixture(workspace, 'untrusted-recovery'); + fs.writeFileSync( + path.join(untrustedRepo.repo, '.changeset/release-pending.json'), + '{"sourceSha":"fixture"}\n', + ); + commitAndPush(untrustedRepo.repo, 'add untrusted recovery marker'); + assert.throws( + () => + inspectReleaseState( + workspace, + untrustedRepo, + 'untrusted-recovery', + ), + /release recovery marker was not created by the release workflow/, + ); +}; - runVersionBlock(pendingRepo.repo, versionBlock, pendingPnpm); +const testVersionLifecycle = (workspace) => { + const fixture = makeGitFixture(workspace, 'version-lifecycle'); + const fakePnpm = makeFakePnpm(workspace, 'version-lifecycle'); + const sourceSha = run('git', ['rev-parse', 'HEAD'], { cwd: fixture.repo }); + const versionBlock = extractRunCommand('Version packages from changesets') + .split('${{ needs.preflight.outputs.release-sha }}') + .join(sourceSha); + + runVersionBlock(fixture.repo, versionBlock, fakePnpm); assert.equal( - fs.readFileSync(path.join(pendingRepo.repo, 'versioned.txt'), 'utf8'), + fs.readFileSync(path.join(fixture.repo, 'versioned.txt'), 'utf8'), 'versioned\n', 'pending changesets should run version-packages', ); + assert.deepEqual( + JSON.parse( + fs.readFileSync( + path.join(fixture.repo, '.changeset/release-pending.json'), + 'utf8', + ), + ), + { sourceSha }, + 'versioning should create a durable release marker', + ); + assert.deepEqual( + JSON.parse( + fs.readFileSync( + path.join(fixture.repo, '.changeset/release-plan.json'), + 'utf8', + ), + ).releases, + [{ name: '@agentic-react/core', newVersion: '1.0.0' }], + 'versioning should preserve the package release plan', + ); + assert.equal( + run('git', ['log', '-1', '--pretty=%s'], { cwd: fixture.repo }), + 'initial fixture', + 'versioning should not commit before validation', + ); + + runShellBlock(fixture.repo, extractRunCommand('Commit version packages')); + assert.equal( + run('git', ['log', '-1', '--pretty=%s'], { cwd: fixture.repo }), + 'chore(release): version packages', + 'validated version state should be committed', + ); assert.equal( - run('git', ['log', '-1', '--pretty=%s'], { cwd: pendingRepo.repo }), - 'chore: version packages', - 'pending changesets should create the expected version commit', + run('git', ['rev-parse', 'HEAD'], { cwd: fixture.repo }), + run('git', ['--git-dir', fixture.origin, 'rev-parse', 'main']), + 'validated version commit should be pushed to main', ); + + runShellBlock(fixture.repo, extractRunCommand('Reconcile release tags')); + assert.equal( + run('git', ['tag', '--list', '@agentic-react/core@1.0.0'], { + cwd: fixture.repo, + }), + '@agentic-react/core@1.0.0', + 'successful recovery should restore tags from the release plan', + ); + + runShellBlock(fixture.repo, extractRunCommand('Clear recovery state')); assert.equal( - run('git', ['rev-parse', 'HEAD'], { cwd: pendingRepo.repo }), - run('git', ['--git-dir', pendingRepo.origin, 'rev-parse', 'main']), - 'version commit should be pushed back to main', + fs.existsSync( + path.join(fixture.repo, '.changeset/release-pending.json'), + ), + false, + 'successful release cleanup should remove the recovery marker', + ); +}; + +const makeVersionedReleaseFixture = (workspace, name, tamperPlan = false) => { + const repo = path.join(workspace, name); + const changesetsBin = path.join( + rootDir, + 'node_modules/@changesets/cli/bin.js', + ); + + run('git', ['clone', '--quiet', '--no-local', rootDir, repo]); + // Keep the fixture independent from shallow CI history and remote refs. + fs.rmSync(path.join(repo, '.git'), { force: true, recursive: true }); + run('git', ['init'], { cwd: repo }); + run('git', ['config', 'user.name', 'Release Smoke'], { cwd: repo }); + run('git', ['config', 'user.email', 'release-smoke@example.com'], { + cwd: repo, + }); + run('git', ['branch', '-M', 'main'], { cwd: repo }); + run('git', ['add', '.'], { cwd: repo }); + run('git', ['commit', '-m', 'release smoke fixture'], { cwd: repo }); + fs.writeFileSync( + path.join(repo, '.changeset/release-core.md'), + '---\n"@agentic-react/core": patch\n---\n\nRelease core.\n', + ); + run('git', ['add', '.changeset/release-core.md'], { cwd: repo }); + run('git', ['commit', '-m', 'add core release'], { cwd: repo }); + + const sourceSha = run('git', ['rev-parse', 'HEAD'], { cwd: repo }); + run('node', [changesetsBin, 'status', '--output', '.changeset/release-plan.json'], { + cwd: repo, + }); + run('node', [changesetsBin, 'version'], { cwd: repo }); + fs.writeFileSync( + path.join(repo, '.changeset/release-pending.json'), + `${JSON.stringify({ sourceSha }, undefined, 2)}\n`, + ); + + if (tamperPlan) { + const planPath = path.join(repo, '.changeset/release-plan.json'); + const plan = JSON.parse(fs.readFileSync(planPath, 'utf8')); + plan.releases[0].newVersion = '99.0.0'; + fs.writeFileSync(planPath, `${JSON.stringify(plan, undefined, 2)}\n`); + } + + run('git', ['add', '.'], { cwd: repo }); + run( + 'git', + [ + '-c', + 'user.name=github-actions[bot]', + '-c', + 'user.email=41898282+github-actions[bot]@users.noreply.github.com', + 'commit', + '-m', + 'chore(release): version packages', + ], + { cwd: repo }, + ); + + return { + repo, + releaseSha: run('git', ['rev-parse', 'HEAD'], { cwd: repo }), + }; +}; + +const testRecoveryTreeVerification = (workspace) => { + const validationBlock = extractRunCommand('Validate recovery state'); + const valid = makeVersionedReleaseFixture( + workspace, + 'valid-recovery-tree', + ); + runShellBlock( + valid.repo, + validationBlock + .split('${{ needs.preflight.outputs.release-sha }}') + .join(valid.releaseSha), + { GITHUB_WORKSPACE: rootDir }, + ); + + const tampered = makeVersionedReleaseFixture( + workspace, + 'tampered-recovery-tree', + true, + ); + assert.throws( + () => + runShellBlock( + tampered.repo, + validationBlock + .split('${{ needs.preflight.outputs.release-sha }}') + .join(tampered.releaseSha), + { GITHUB_WORKSPACE: rootDir }, + ), + /failed with exit code 1/, + 'recovery should reject a forged release plan even with bot metadata', ); }; @@ -337,9 +649,11 @@ const workspace = fs.mkdtempSync( try { const tests = [ ['workflow contract', () => testWorkflowContract()], + ['release preflight', () => testReleasePreflight(workspace)], + ['version lifecycle', () => testVersionLifecycle(workspace)], [ - 'pending changeset detection', - () => testPendingChangesetDetection(workspace), + 'recovery tree verification', + () => testRecoveryTreeVerification(workspace), ], ['release tag push', () => testTagPush(workspace)], [