From f1620a48fb838a91bf70b9a947cbae8f6d398b2e Mon Sep 17 00:00:00 2001 From: jazelly Date: Sat, 18 Jul 2026 22:31:31 +0930 Subject: [PATCH 1/6] feat(ci): harden release workflow and improve project README --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 193 ++++++++- README.md | 29 +- README.zh-CN.md | 285 ++++++++++++++ docs/plans/toolbox-icon-customization.md | 482 +++++++++++++++++++++++ scripts/release-smoke-test.mjs | 369 +++++++++++++++-- 6 files changed, 1314 insertions(+), 50 deletions(-) create mode 100644 README.zh-CN.md create mode 100644 docs/plans/toolbox-icon-customization.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd8cd61..77c53ff 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,7 +36,7 @@ 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56ad0aa..75671cf 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,108 @@ 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: 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 +227,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..687c280 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. 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..4d174ab --- /dev/null +++ b/docs/plans/toolbox-icon-customization.md @@ -0,0 +1,482 @@ +# Toolbox Icon Customization Implementation Plan + +## Status + +Proposed implementation plan based on the completed product-design grilling session. + +## Goal + +Allow developers to customize the Agentic React toolbox launcher icon during local development in either of two ways: + +1. Define a project default in the supported bundler adapter configuration. +2. Upload, crop, and apply a project-specific personal override from the Settings UI. + +The feature must remain development-only when Agentic React is installed through a bundler adapter. It must not add the toolbox runtime, the selected icon, or project-settings code to a production client bundle. + +## Product Decisions + +### Ownership and precedence + +The bundler configuration defines the shared project default. A Settings upload defines a personal override for the current project on the current machine. + +The normal source precedence is: + +```text +valid local project override + > valid bundler-configured icon + > package-provided default icon +``` + +If the currently selected custom source fails to load or validate, render the package-provided default icon directly. Do not silently try a different custom source. Resetting the local override explicitly reveals the bundler-configured icon again. + +The Settings UI must identify the active source as `Local override`, `Project configuration`, or `Default`. + +### Persistence scope + +Local overrides are project-specific, not browser-origin-specific. The same override must work when the project changes port, hostname, or supported bundler on the same machine. + +Persist data under: + +```text +~/.agentic-react/projects// +``` + +Use the SHA-256 hash of the canonical, real-path-resolved project root as ``. A project moved to a different canonical path is treated as a new project. + +The project directory contains only versioned plugin-owned data: + +```text +settings.json +toolbox-icon.webp +``` + +Use `toolbox-icon.png` only when PNG is required as the encoding fallback. Do not write into the repository and do not modify `.gitignore`. + +Suggested settings schema: + +```json +{ + "schemaVersion": 1, + "toolboxIcon": { + "fileName": "toolbox-icon.webp", + "mimeType": "image/webp", + "updatedAt": "2026-07-18T00:00:00.000Z" + } +} +``` + +### Upload and crop behavior + +The Settings UI accepts a local image selected through the browser file picker. It does not accept a pasted URL. URL-based icons remain a bundler configuration feature. + +Every upload enters a 1:1 crop workflow with: + +- a circular mask preview matching the launcher; +- drag-to-position; +- zoom; +- 90-degree rotation; +- a default centered crop that can be accepted without adjustment; +- explicit `Apply` and `Cancel` actions. + +Preview changes are transient. The launcher and persisted data change only after `Apply` succeeds. `Cancel` has no side effects. + +The encoded result is a square `256 x 256` static image. Prefer WebP and fall back to PNG when WebP encoding is unavailable or PNG is required to preserve the result correctly. Preserve transparency and discard source metadata, including EXIF and GPS metadata. + +Animated inputs produce a single static frame. SVG input and animated output are not supported in the first version. + +### Upload validation + +Validate decoded content rather than trusting the extension or browser-reported MIME type. + +Apply all of these limits to local uploads and configured local files: + +- maximum source file size: `20 MB`; +- maximum width or height: `8192 px`; +- maximum decoded pixel count: `40 megapixels`; +- source must be a browser-decodable raster image; +- SVG must be rejected explicitly; +- malformed or undecodable content must be rejected. + +Perform browser decoding before opening the crop editor. Apply EXIF orientation during decoding. Never send the original upload to Node; send only the encoded `256 x 256` result through the local bridge. + +Remote configuration URLs are loaded directly by the browser and are not proxied or copied into local storage. Enforce protocol and decoded-dimension checks where possible, but do not download a remote asset in Node merely to enforce the byte-size limit. + +### Failure behavior + +Never display a broken-image indicator. + +- Decode a candidate completely before swapping the visible launcher image. +- Keep the previous visible icon until an `Apply` operation has been persisted successfully. +- If the active custom source cannot load, show the package-provided default icon. +- Keep a failed local setting record so Settings can show the error and offer `Replace` or `Reset`. +- Log a bundler local-file problem once during dev-server startup. +- Log a browser URL load problem once per page session. +- Write files through a temporary sibling followed by an atomic rename. +- Do not leave partial settings or image files after an interrupted write. + +### Runtime-only behavior + +Direct `@agentic-react/core` consumers do not have project-root knowledge or Node persistence. + +- Keep code-based URL configuration available. +- Hide upload, crop, apply, and reset controls when the project-settings bridge capability is absent. +- Show a short explanation that icon upload requires a bundler adapter. +- Do not introduce a `localStorage` or IndexedDB fallback. + +### No customization-disable option + +Do not add an `iconCustomization` enable/disable option. Upload is available whenever a supported development adapter exposes the project-settings capability. + +## Public Configuration API + +Introduce an explicit icon source union: + +```ts +export type ToolkitIconSource = + | { + type: 'url'; + url: string; + } + | { + type: 'file'; + path: string; + }; + +export interface ToolkitConfig { + // Existing fields omitted. + icon?: ToolkitIconSource; + + /** @deprecated Use icon: { type: 'url', url } instead. */ + iconUrl?: string; +} +``` + +Configuration examples: + +```ts +AgenticReact({ + toolkit: { + icon: { + type: 'url', + url: '/agentic-react-logo.png', + }, + }, +}); +``` + +```ts +withAgenticReactWebpack(config, { mode: argv.mode }, { + toolkit: { + icon: { + type: 'file', + path: './branding/toolbox-icon.png', + }, + }, +}); +``` + +Rules: + +- `url` accepts relative public URLs, `http:`, `https:`, and raster `data:image/...` URLs. +- Reject `javascript:`, `file:`, `blob:`, SVG data URLs, and unknown protocols. +- Set `referrerPolicy="no-referrer"` for remote images. +- Do not proxy remote URLs. +- Resolve relative `file` paths from the adapter's resolved project root. +- Permit absolute `file` paths because the bundler configuration is trusted local code. +- Expose only the resolved configured file; never provide a general-purpose file-serving path. +- When `icon` and legacy `iconUrl` are both present, `icon` wins and a development warning is emitted once. +- Preserve `iconUrl` for backward compatibility during the initial release of the new API. +- Document that `file` requires a bundler adapter; runtime-only consumers should use `url`. + +## Architecture + +### Browser responsibilities + +Keep browser-specific image work in `@agentic-react/core`: + +- file selection; +- format sniffing and browser decode validation; +- EXIF-aware rendering; +- crop, zoom, and rotation state; +- canvas encoding to WebP or PNG; +- image preloading before launcher replacement; +- Settings UI states and errors; +- sending only the final encoded image to the project-settings bridge. + +Do not add more Settings and crop logic directly to the already-large `selection_toolkit.ts`. Extract cohesive modules, for example: + +```text +packages/core/src/core/settings/toolbox_icon_settings.ts +packages/core/src/core/settings/toolbox_icon_cropper.ts +packages/core/src/core/settings/image_validation.ts +packages/core/src/core/settings/project_settings_client.ts +``` + +The selection toolkit should consume a small interface that exposes the effective icon, opens the Settings surface, and applies runtime updates. + +### Node responsibilities + +Create one shared Node implementation used by all adapters, for example: + +```text +packages/core/src/project_settings/toolbox_icon_store.ts +packages/core/src/project_settings/project_identity.ts +packages/core/src/project_settings/configured_icon_resolver.ts +``` + +Expose it through a Node-only package subpath such as `@agentic-react/core/project-settings`. Do not make browser code depend on Node built-ins. + +The shared implementation owns: + +- canonical project-root identity and hashing; +- `~/.agentic-react` path construction; +- settings schema parsing and migration; +- configured local-file resolution; +- bounded base64 decoding of the final crop result; +- MIME/magic-byte verification for the encoded result; +- atomic apply and reset operations; +- reading the effective local override at dev startup; +- returning capability and source metadata to the browser. + +### Bridge protocol + +Reuse the existing local runtime bridge so Vite, Webpack, and Next share one persistence mechanism. Do not add adapter-specific public upload APIs. + +Add a narrow browser-to-Node request surface: + +```text +project-settings:get-toolbox-icon +project-settings:apply-toolbox-icon +project-settings:reset-toolbox-icon +``` + +Messages must use fixed operations and must never accept a filesystem path from the browser. Cap the encoded result at a conservative post-crop size, such as `1 MB`, before base64 decoding. + +Because this adds browser-initiated mutations to the local bridge, protect the dev session internally with an automatically generated capability. This is an implementation safeguard, not a public setting and not a restriction on image origin. It must require no user interaction. + +Return a typed result containing: + +- success or structured error; +- active source; +- effective image as a small data URL when needed; +- persisted MIME type and update timestamp; +- project-settings capability availability. + +### Initial icon resolution + +Avoid a visible flash from the project icon to the local override. + +At development entry generation time, each adapter should: + +1. Resolve and validate the bundler-configured icon source. +2. Read the project-specific local override. +3. Compute the initial selected source using the agreed precedence. +4. Inject a resolved browser-safe URL or small data URL plus source metadata into `AgenticReactConfig`. + +After an Apply or Reset response, update the launcher in place through the runtime API without a page reload. + +## Adapter Work + +### Vite + +Update `packages/vite/src/index.ts` to: + +- resolve `toolkit.icon` during development; +- initialize the shared project settings store from `config.root`; +- attach project-settings handlers to the existing runtime bridge; +- inject effective icon and capability metadata in `transformIndexHtml`; +- retain `apply: 'serve'` so none of this runs for `vite build`. + +### Webpack + +Update `packages/webpack/src/index.ts` to fail closed for production. + +Resolve mode from explicit sources in this order: + +```text +env.mode +config.mode +process.env.NODE_ENV +``` + +Inject Agentic React only when the resolved mode is exactly `development`. If mode is absent or ambiguous, return the original configuration without writing a generated client entry. Emit a concise warning explaining how to pass development mode. + +Then: + +- initialize the shared store from `options.rootDir`, `config.context`, or the resolved root; +- resolve `toolkit.icon` before generating the browser entry; +- attach project-settings handlers to the existing runtime bridge; +- inject the effective icon and settings capability into the generated entry. + +### Next.js + +Update `packages/next/src/index.ts` to: + +- keep the existing `context.dev && !context.isServer` injection gate; +- initialize the shared store using the resolved root; +- attach project-settings handlers to the existing Next bridge server; +- inject the effective icon and capability into the generated client entry; +- avoid relying on HTTP same-origin, because the Next bridge intentionally uses a separate loopback port by default. + +### Core runtime + +Update the shared types, global declarations, overlay initialization, and selection toolkit integration to: + +- understand the new icon source and resolved source metadata; +- keep legacy `iconUrl` working; +- expose project-settings capability to the Settings UI; +- preload and atomically swap launcher images; +- open the upload/crop flow from the Settings appearance section; +- update the launcher after Apply or Reset without reload; +- fall back directly to the packaged default icon on custom-source failure. + +## Settings UI Specification + +Add an `Appearance` section with a `Toolbox icon` row. + +The resting state contains: + +- a circular preview at launcher size; +- a source badge; +- a `Change` action when adapter persistence is available; +- a `Reset to project default` action only when a local override exists; +- an inline error when the persisted override is unavailable or invalid. + +The crop state contains: + +- the source image viewport; +- a fixed square crop boundary with circular launcher preview mask; +- drag and keyboard positioning; +- a labeled zoom slider; +- left and right 90-degree rotation controls; +- final circular preview; +- `Cancel` and `Apply` buttons; +- progress and failure states that prevent duplicate Apply operations. + +Accessibility requirements: + +- all controls are keyboard operable; +- slider and rotation controls have accessible names; +- focus is trapped inside the crop modal and restored to `Change` on close; +- errors are announced through an appropriate live region; +- the preview is decorative when adjacent text already communicates its purpose; +- reduced-motion preferences are respected. + +## Production-Safety Invariant + +The invariant is: + +> A bundler adapter must never inject Agentic React into a production client compilation. Ambiguous build mode is treated as non-development. + +Required safeguards: + +- Vite remains `apply: 'serve'`. +- Next remains gated by `context.dev` and client compilation. +- Webpack changes from development-by-default to explicit-development-only. +- No production adapter path writes generated Agentic React entries. +- No production output contains the bridge path, toolbox launcher marker, default icon data URL, project-settings events, or `~/.agentic-react` references. +- Direct imports from `@agentic-react/core` remain an intentional runtime-only API and are documented as outside the adapter guarantee. + +## Testing Strategy + +### Unit tests + +Add focused tests for: + +- project-root canonicalization and stable hashing; +- per-project directory isolation; +- settings schema parsing, unknown-version rejection, and migration hooks; +- atomic apply and reset behavior; +- temporary-file cleanup after failure; +- valid WebP and PNG result verification; +- size and protocol rejection; +- icon precedence and direct-default failure behavior; +- legacy `iconUrl` compatibility and `icon` precedence; +- relative and absolute configured-file resolution; +- browser validation boundaries as pure functions; +- crop math for pan, zoom, rotation, and output coordinates. + +Use temporary home and project directories in Node tests. Never read or write the developer's real `~/.agentic-react` directory during tests. + +### Adapter tests + +Add production-gate tests for all supported adapters: + +- Vite build does not invoke Agentic React HTML injection. +- Webpack development mode prepends the generated entry. +- Webpack production, missing, and unknown modes return the original entry. +- Next development client compilation injects the entry. +- Next production and server compilations do not inject it. + +Add adapter contract tests proving that Vite, Webpack, and Next calculate the same project identity and expose the same settings capability/result shape. + +### Browser and end-to-end tests + +Extend Playwright coverage to verify: + +- upload opens the crop workflow; +- oversized, SVG, malformed, and over-dimension inputs are rejected; +- preview interactions do not change the launcher before Apply; +- Cancel leaves the old icon unchanged; +- Apply persists and updates without reload; +- reload restores the local override; +- changing the dev-server port preserves the same project override; +- Reset reveals the configured project icon; +- Reset with no configured icon reveals the packaged default; +- broken local, configured-file, remote, and data URL sources show the packaged default rather than a broken image; +- runtime-only mode does not show upload controls; +- remote config URLs receive `no-referrer` behavior; +- transparent output renders correctly in the circular launcher. + +Run the flow in the Vite, Webpack, and Next playgrounds. One adapter should own the full interaction matrix; the other adapters can run smaller persistence and capability smoke tests to avoid duplicating slow UI coverage. + +### Production artifact tests + +Build production fixtures for Vite, Webpack, and Next, then scan emitted client artifacts for forbidden Agentic React runtime markers. This catches regressions where a gate appears correct at configuration time but generated entries still enter the final bundle. + +## Documentation and Migration + +Update the English and Chinese READMEs with: + +- Settings upload behavior; +- storage location and project-specific semantics; +- format, dimension, and file-size rules; +- `Apply`, `Cancel`, and Reset behavior; +- the `url | file` configuration examples for every adapter; +- `iconUrl` deprecation guidance; +- runtime-only limitations; +- the production-safety guarantee and explicit-development Webpack usage. + +Add a changeset describing the new customization feature, the backward-compatible configuration API addition, and the safer Webpack production gate. + +Do not remove `iconUrl` in the same release. Consider removal only in a future major version after documented deprecation. + +## Delivery Sequence + +1. Add the public types, resolved internal types, and compatibility rules. +2. Implement and test the project identity and Node persistence store. +3. Extend the bridge with the narrow project-settings protocol. +4. Resolve configured URL/file sources and initial local overrides in each adapter. +5. Harden the Webpack production gate and add production tests before enabling UI writes. +6. Extract browser image validation, crop math, and encoding modules. +7. Add the Settings appearance row and staged crop workflow. +8. Wire Apply, Reset, preload, source metadata, and direct-default fallback. +9. Add adapter and Playwright coverage across Vite, Webpack, and Next. +10. Update playgrounds, READMEs, changelog metadata, and release smoke coverage. + +Each sequence item should leave the repository buildable. Land the production-gate hardening before or in the same change that introduces persisted customization. + +## Acceptance Criteria + +- A user can upload a supported raster image, crop it to a `256 x 256` static icon, preview it, and persist it only by clicking Apply. +- The override is shared by all dev-server origins and supported bundlers for the same canonical project root on the same machine. +- A different project does not inherit the override. +- Reset removes only the personal override and reveals the project-configured icon or packaged default. +- Bundler configuration supports explicit URL and file sources while legacy `iconUrl` continues to work. +- Remote URL sources are permitted without being proxied or copied. +- Invalid custom sources never produce a broken launcher image and fall back directly to the packaged default. +- Runtime-only usage does not expose a nonfunctional upload flow. +- No user-facing security, origin, token, or customization-enable setting is introduced. +- Vite, Webpack, and Next production client builds contain no adapter-injected Agentic React runtime or icon customization code. +- The feature is documented and covered by unit, adapter, browser, and production-artifact tests. + diff --git a/scripts/release-smoke-test.mjs b/scripts/release-smoke-test.mjs index db1b471..2bd9922 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,233 @@ 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'); - runVersionBlock(pendingRepo.repo, versionBlock, pendingPnpm); + 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/, + ); +}; + +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: pendingRepo.repo }), - 'chore: version packages', - 'pending changesets should create the expected version commit', + 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', ['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]); + run('git', ['checkout', '-B', 'main', 'origin/main'], { cwd: repo }); + run('git', ['config', 'user.name', 'Release Smoke'], { cwd: repo }); + run('git', ['config', 'user.email', 'release-smoke@example.com'], { + 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 +644,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)], [ From ebd20eda4c231bbbe6cd36d8e41336a67158b6f8 Mon Sep 17 00:00:00 2001 From: jazelly Date: Sun, 26 Jul 2026 21:23:25 +0930 Subject: [PATCH 2/6] feat(agentic-react): update application features and components --- docs/plans/toolbox-icon-customization.md | 585 +++++-------- packages/core/src/bridge/server.ts | 73 +- packages/core/src/core/settings/browser.ts | 134 +++ packages/core/src/core/settings/icon_modal.ts | 345 ++++++++ .../core/src/core/settings/image_cropper.ts | 642 ++++++++++++++ packages/core/src/core/settings/node.ts | 800 ++++++++++++++++++ packages/core/src/core/settings/schema.ts | 396 +++++++++ packages/core/src/core/settings/shortcuts.ts | 419 +++++++++ packages/core/src/core/settings/ui.ts | 117 +++ .../core/src/core/tools/selection_toolkit.ts | 740 ++++++++++++++-- packages/core/src/global.d.ts | 2 + packages/core/src/index.ts | 25 + packages/core/src/overlay.js | 92 ++ packages/core/src/shared/protocol.ts | 14 +- packages/core/src/shared/types.ts | 119 ++- packages/core/test/image-cropper.test.mjs | 53 ++ packages/core/test/settings.test.mjs | 685 +++++++++++++++ packages/core/test/shortcuts.test.mjs | 85 ++ packages/next/src/index.ts | 86 +- packages/next/test/next-entry.test.mjs | 1 + packages/vite/src/index.ts | 32 +- packages/webpack/package.json | 3 +- packages/webpack/src/index.ts | 49 +- packages/webpack/test/webpack-mode.test.mjs | 112 +++ .../e2e/global-setup.js | 15 + .../e2e/selection-context.spec.js | 436 ++++++++-- .../playwright.config.js | 12 + .../vite.config.js | 9 + 28 files changed, 5579 insertions(+), 502 deletions(-) create mode 100644 packages/core/src/core/settings/browser.ts create mode 100644 packages/core/src/core/settings/icon_modal.ts create mode 100644 packages/core/src/core/settings/image_cropper.ts create mode 100644 packages/core/src/core/settings/node.ts create mode 100644 packages/core/src/core/settings/schema.ts create mode 100644 packages/core/src/core/settings/shortcuts.ts create mode 100644 packages/core/src/core/settings/ui.ts create mode 100644 packages/core/test/image-cropper.test.mjs create mode 100644 packages/core/test/settings.test.mjs create mode 100644 packages/core/test/shortcuts.test.mjs create mode 100644 packages/webpack/test/webpack-mode.test.mjs create mode 100644 playground/agentic-react-vite-playground/e2e/global-setup.js diff --git a/docs/plans/toolbox-icon-customization.md b/docs/plans/toolbox-icon-customization.md index 4d174ab..6eec656 100644 --- a/docs/plans/toolbox-icon-customization.md +++ b/docs/plans/toolbox-icon-customization.md @@ -1,482 +1,325 @@ -# Toolbox Icon Customization Implementation Plan +# Toolbox Settings and Selection Confirmation Plan ## Status -Proposed implementation plan based on the completed product-design grilling session. +Accepted implementation plan. The global settings engine and browser integration described here are the source of truth for this feature. ## Goal -Allow developers to customize the Agentic React toolbox launcher icon during local development in either of two ways: +Give the Agentic React toolbox one coherent Settings surface for: -1. Define a project default in the supported bundler adapter configuration. -2. Upload, crop, and apply a project-specific personal override from the Settings UI. +- single-select, multiselect, toolbox-toggle, and Done shortcuts; +- a globally customized launcher icon; +- clear source labels and per-setting reset actions. -The feature must remain development-only when Agentic React is installed through a bundler adapter. It must not add the toolbox runtime, the selected icon, or project-settings code to a production client bundle. +At the same time, make selection an explicit transaction: -## Product Decisions - -### Ownership and precedence - -The bundler configuration defines the shared project default. A Settings upload defines a personal override for the current project on the current machine. - -The normal source precedence is: +- 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. -```text -valid local project override - > valid bundler-configured icon - > package-provided default icon -``` +All implementation and defaults live in this repository. A consuming project only supplies optional project defaults through its Agentic React configuration. -If the currently selected custom source fails to load or validate, render the package-provided default icon directly. Do not silently try a different custom source. Resetting the local override explicitly reveals the bundler-configured icon again. - -The Settings UI must identify the active source as `Local override`, `Project configuration`, or `Default`. +## Product Decisions -### Persistence scope +### Global ownership and precedence -Local overrides are project-specific, not browser-origin-specific. The same override must work when the project changes port, hostname, or supported bundler on the same machine. +User settings are global to the local machine. They are not scoped to a repository, browser origin, hostname, port, or bundler. -Persist data under: +The precedence for every configurable value is: ```text -~/.agentic-react/projects// +global user override + > project configuration default + > package default ``` -Use the SHA-256 hash of the canonical, real-path-resolved project root as ``. A project moved to a different canonical path is treated as a new project. +Reset removes only the global override. It reveals the project default when one exists, otherwise the package default. -The project directory contains only versioned plugin-owned data: +The Settings UI identifies the effective source as `Global override`, `Project configuration`, or `Default`. -```text -settings.json -toolbox-icon.webp -``` +### Persistence -Use `toolbox-icon.png` only when PNG is required as the encoding fallback. Do not write into the repository and do not modify `.gitignore`. +Persist settings under the user's home directory: -Suggested settings schema: - -```json -{ - "schemaVersion": 1, - "toolboxIcon": { - "fileName": "toolbox-icon.webp", - "mimeType": "image/webp", - "updatedAt": "2026-07-18T00:00:00.000Z" - } -} +```text +~/.agentic-react/settings.json +~/.agentic-react/toolbox-icon.webp ``` -### Upload and crop behavior - -The Settings UI accepts a local image selected through the browser file picker. It does not accept a pasted URL. URL-based icons remain a bundler configuration feature. - -Every upload enters a 1:1 crop workflow with: - -- a circular mask preview matching the launcher; -- drag-to-position; -- zoom; -- 90-degree rotation; -- a default centered crop that can be accepted without adjustment; -- explicit `Apply` and `Cancel` actions. - -Preview changes are transient. The launcher and persisted data change only after `Apply` succeeds. `Cancel` has no side effects. - -The encoded result is a square `256 x 256` static image. Prefer WebP and fall back to PNG when WebP encoding is unavailable or PNG is required to preserve the result correctly. Preserve transparency and discard source metadata, including EXIF and GPS metadata. - -Animated inputs produce a single static frame. SVG input and animated output are not supported in the first version. - -### Upload validation - -Validate decoded content rather than trusting the extension or browser-reported MIME type. - -Apply all of these limits to local uploads and configured local files: - -- maximum source file size: `20 MB`; -- maximum width or height: `8192 px`; -- maximum decoded pixel count: `40 megapixels`; -- source must be a browser-decodable raster image; -- SVG must be rejected explicitly; -- malformed or undecodable content must be rejected. - -Perform browser decoding before opening the crop editor. Apply EXIF orientation during decoding. Never send the original upload to Node; send only the encoded `256 x 256` result through the local bridge. - -Remote configuration URLs are loaded directly by the browser and are not proxied or copied into local storage. Enforce protocol and decoded-dimension checks where possible, but do not download a remote asset in Node merely to enforce the byte-size limit. - -### Failure behavior - -Never display a broken-image indicator. +Use `toolbox-icon.png` only when PNG is required as the encoding fallback. -- Decode a candidate completely before swapping the visible launcher image. -- Keep the previous visible icon until an `Apply` operation has been persisted successfully. -- If the active custom source cannot load, show the package-provided default icon. -- Keep a failed local setting record so Settings can show the error and offer `Replace` or `Reset`. -- Log a bundler local-file problem once during dev-server startup. -- Log a browser URL load problem once per page session. -- Write files through a temporary sibling followed by an atomic rename. -- Do not leave partial settings or image files after an interrupted write. +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. -### Runtime-only behavior +Tests and playgrounds must inject an isolated settings root and must never read or write the developer's real `~/.agentic-react` directory. -Direct `@agentic-react/core` consumers do not have project-root knowledge or Node persistence. +### Why browser code can use home-directory settings -- Keep code-based URL configuration available. -- Hide upload, crop, apply, and reset controls when the project-settings bridge capability is absent. -- Show a short explanation that icon upload requires a bundler adapter. -- Do not introduce a `localStorage` or IndexedDB fallback. +Browser code cannot read the local filesystem directly. Persistence is therefore a runtime operation, not a bundler-time file import: -### No customization-disable option +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. -Do not add an `iconCustomization` enable/disable option. Upload is available whenever a supported development adapter exposes the project-settings capability. +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. -## Public Configuration API +No filesystem path is sent to the browser. Persisted icons are returned as bounded, validated data URLs. -Introduce an explicit icon source union: +### No browser-storage fallback or file watcher -```ts -export type ToolkitIconSource = - | { - type: 'url'; - url: string; - } - | { - type: 'file'; - path: string; - }; +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. -export interface ToolkitConfig { - // Existing fields omitted. - icon?: ToolkitIconSource; +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. - /** @deprecated Use icon: { type: 'url', url } instead. */ - iconUrl?: string; -} -``` +## Selection Semantics -Configuration examples: - -```ts -AgenticReact({ - toolkit: { - icon: { - type: 'url', - url: '/agentic-react-logo.png', - }, - }, -}); -``` +### Single select -```ts -withAgenticReactWebpack(config, { mode: argv.mode }, { - toolkit: { - icon: { - type: 'file', - path: './branding/toolbox-icon.png', - }, - }, -}); -``` +Single select uses a pending transaction: -Rules: +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. -- `url` accepts relative public URLs, `http:`, `https:`, and raster `data:image/...` URLs. -- Reject `javascript:`, `file:`, `blob:`, SVG data URLs, and unknown protocols. -- Set `referrerPolicy="no-referrer"` for remote images. -- Do not proxy remote URLs. -- Resolve relative `file` paths from the adapter's resolved project root. -- Permit absolute `file` paths because the bundler configuration is trusted local code. -- Expose only the resolved configured file; never provide a general-purpose file-serving path. -- When `icon` and legacy `iconUrl` are both present, `icon` wins and a development warning is emitted once. -- Preserve `iconUrl` for backward compatibility during the initial release of the new API. -- Document that `file` requires a bundler adapter; runtime-only consumers should use `url`. +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. -## Architecture +If clipboard writing fails, keep the pending selection so the user can retry. -### Browser responsibilities +### Multiselect -Keep browser-specific image work in `@agentic-react/core`: +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. -- file selection; -- format sniffing and browser decode validation; -- EXIF-aware rendering; -- crop, zoom, and rotation state; -- canvas encoding to WebP or PNG; -- image preloading before launcher replacement; -- Settings UI states and errors; -- sending only the final encoded image to the project-settings bridge. +### Escape -Do not add more Settings and crop logic directly to the already-large `selection_toolkit.ts`. Extract cohesive modules, for example: +Escape is reserved and is not configurable. -```text -packages/core/src/core/settings/toolbox_icon_settings.ts -packages/core/src/core/settings/toolbox_icon_cropper.ts -packages/core/src/core/settings/image_validation.ts -packages/core/src/core/settings/project_settings_client.ts -``` +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. -The selection toolkit should consume a small interface that exposes the effective icon, opens the Settings surface, and applies runtime updates. +Escape: -### Node responsibilities +- 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`. -Create one shared Node implementation used by all adapters, for example: +## Host Event Isolation -```text -packages/core/src/project_settings/toolbox_icon_store.ts -packages/core/src/project_settings/project_identity.ts -packages/core/src/project_settings/configured_icon_resolver.ts -``` +Every interactive Agentic React surface must form an activation-event boundary, including: -Expose it through a Node-only package subpath such as `@agentic-react/core/project-settings`. Do not make browser code depend on Node built-ins. +- launcher and toolbox panel; +- Settings controls; +- selected-element actions; +- multiselect actions; +- tuning UI; +- icon crop modal and backdrop. -The shared implementation owns: - -- canonical project-root identity and hashing; -- `~/.agentic-react` path construction; -- settings schema parsing and migration; -- configured local-file resolution; -- bounded base64 decoding of the final crop result; -- MIME/magic-byte verification for the encoded result; -- atomic apply and reset operations; -- reading the effective local override at dev startup; -- returning capability and source metadata to the browser. - -### Bridge protocol - -Reuse the existing local runtime bridge so Vite, Webpack, and Next share one persistence mechanism. Do not add adapter-specific public upload APIs. - -Add a narrow browser-to-Node request surface: +At a minimum, stop propagation for: ```text -project-settings:get-toolbox-icon -project-settings:apply-toolbox-icon -project-settings:reset-toolbox-icon +pointerdown +pointerup +mousedown +mouseup +click +touchstart +touchend +contextmenu ``` -Messages must use fixed operations and must never accept a filesystem path from the browser. Cap the encoded result at a conservative post-crop size, such as `1 MB`, before base64 decoding. +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. -Because this adds browser-initiated mutations to the local bridge, protect the dev session internally with an automatically generated capability. This is an implementation safeguard, not a public setting and not a restriction on image origin. It must require no user interaction. +## Settings UI -Return a typed result containing: +Add a Settings section inside the existing toolbox panel. It contains `Shortcuts` and `Appearance` subsections. -- success or structured error; -- active source; -- effective image as a small data URL when needed; -- persisted MIME type and update timestamp; -- project-settings capability availability. +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. -### Initial icon resolution +### Shortcuts -Avoid a visible flash from the project icon to the local override. +The configurable actions are: -At development entry generation time, each adapter should: +| Action | Package default | +| --- | --- | +| Single select | `Ctrl+Alt+Shift+S` | +| Multi select | `Ctrl+Alt+Shift+M` | +| Toggle toolbox | `Ctrl+Alt+Shift+A` | +| Done | `Enter` | -1. Resolve and validate the bundler-configured icon source. -2. Read the project-specific local override. -3. Compute the initial selected source using the agreed precedence. -4. Inject a resolved browser-safe URL or small data URL plus source metadata into `AgenticReactConfig`. +Each row shows the action, effective shortcut, source badge, record action, and reset action. -After an Apply or Reset response, update the launcher in place through the runtime API without a page reload. +Recording captures one normalized combination. Validation occurs both in the browser and in the Node settings engine. Reject: -## Adapter Work +- 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. -### Vite +The Node write path validates the merged effective shortcut set so direct runtime/RPC callers cannot bypass UI validation. -Update `packages/vite/src/index.ts` to: +### Appearance -- resolve `toolkit.icon` during development; -- initialize the shared project settings store from `config.root`; -- attach project-settings handlers to the existing runtime bridge; -- inject effective icon and capability metadata in `transformIndexHtml`; -- retain `apply: 'serve'` so none of this runs for `vite build`. +The Toolbox icon row contains: -### Webpack +- a circular preview; +- an effective-source badge; +- Change; +- Reset when a global override exists; +- an inline capability or validation error when applicable. -Update `packages/webpack/src/index.ts` to fail closed for production. +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. -Resolve mode from explicit sources in this order: - -```text -env.mode -config.mode -process.env.NODE_ENV -``` +## Image Customization -Inject Agentic React only when the resolved mode is exactly `development`. If mode is absent or ambiguous, return the original configuration without writing a generated client entry. Emit a concise warning explaining how to pass development mode. +### Crop workflow -Then: +Every accepted upload opens a 1:1 crop modal with: -- initialize the shared store from `options.rootDir`, `config.context`, or the resolved root; -- resolve `toolkit.icon` before generating the browser entry; -- attach project-settings handlers to the existing runtime bridge; -- inject the effective icon and settings capability into the generated entry. - -### Next.js - -Update `packages/next/src/index.ts` to: - -- keep the existing `context.dev && !context.isServer` injection gate; -- initialize the shared store using the resolved root; -- attach project-settings handlers to the existing Next bridge server; -- inject the effective icon and capability into the generated client entry; -- avoid relying on HTTP same-origin, because the Next bridge intentionally uses a separate loopback port by default. - -### Core runtime - -Update the shared types, global declarations, overlay initialization, and selection toolkit integration to: - -- understand the new icon source and resolved source metadata; -- keep legacy `iconUrl` working; -- expose project-settings capability to the Settings UI; -- preload and atomically swap launcher images; -- open the upload/crop flow from the Settings appearance section; -- update the launcher after Apply or Reset without reload; -- fall back directly to the packaged default icon on custom-source failure. - -## Settings UI Specification +- a circular launcher preview; +- drag-to-position; +- zoom; +- left and right 90-degree rotation; +- a centered default crop; +- Apply and Cancel. -Add an `Appearance` section with a `Toolbox icon` row. +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 resting state contains: +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. -- a circular preview at launcher size; -- a source badge; -- a `Change` action when adapter persistence is available; -- a `Reset to project default` action only when a local override exists; -- an inline error when the persisted override is unavailable or invalid. +### Validation limits -The crop state contains: +Validate decoded content rather than trusting the extension or reported MIME type: -- the source image viewport; -- a fixed square crop boundary with circular launcher preview mask; -- drag and keyboard positioning; -- a labeled zoom slider; -- left and right 90-degree rotation controls; -- final circular preview; -- `Cancel` and `Apply` buttons; -- progress and failure states that prevent duplicate Apply operations. +- 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. -Accessibility requirements: +Never send the original upload to Node. Send only the encoded 256-square result. -- all controls are keyboard operable; -- slider and rotation controls have accessible names; -- focus is trapped inside the crop modal and restored to `Change` on close; -- errors are announced through an appropriate live region; -- the preview is decorative when adjacent text already communicates its purpose; -- reduced-motion preferences are respected. +### Failure behavior -## Production-Safety Invariant +- 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. -The invariant is: +### Accessibility -> A bundler adapter must never inject Agentic React into a production client compilation. Ambiguous build mode is treated as non-development. +- 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. -Required safeguards: +## Architecture -- Vite remains `apply: 'serve'`. -- Next remains gated by `context.dev` and client compilation. -- Webpack changes from development-by-default to explicit-development-only. -- No production adapter path writes generated Agentic React entries. -- No production output contains the bridge path, toolbox launcher marker, default icon data URL, project-settings events, or `~/.agentic-react` references. -- Direct imports from `@agentic-react/core` remain an intentional runtime-only API and are documented as outside the adapter guarantee. +### Browser modules -## Testing Strategy +Keep cohesive browser logic under `packages/core/src/core/settings/`: -### Unit tests +- `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. -Add focused tests for: +`selection_toolkit.ts` coordinates selection state and consumes these modules. It owns no filesystem behavior. -- project-root canonicalization and stable hashing; -- per-project directory isolation; -- settings schema parsing, unknown-version rejection, and migration hooks; -- atomic apply and reset behavior; -- temporary-file cleanup after failure; -- valid WebP and PNG result verification; -- size and protocol rejection; -- icon precedence and direct-default failure behavior; -- legacy `iconUrl` compatibility and `icon` precedence; -- relative and absolute configured-file resolution; -- browser validation boundaries as pure functions; -- crop math for pan, zoom, rotation, and output coordinates. +### Node settings engine -Use temporary home and project directories in Node tests. Never read or write the developer's real `~/.agentic-react` directory during tests. +The shared Node implementation under `packages/core/src/core/settings/` owns: -### Adapter tests +- 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. -Add production-gate tests for all supported adapters: +Vite, Webpack, and Next construct the same engine and register it with their existing Agentic React bridge. -- Vite build does not invoke Agentic React HTML injection. -- Webpack development mode prepends the generated entry. -- Webpack production, missing, and unknown modes return the original entry. -- Next development client compilation injects the entry. -- Next production and server compilations do not inject it. +### RPC operations -Add adapter contract tests proving that Vite, Webpack, and Next calculate the same project identity and expose the same settings capability/result shape. +Keep the browser-to-Node request surface fixed and typed: -### Browser and end-to-end tests +```text +settings:get-effective +settings:update-shortcuts +settings:reset-shortcut +settings:reset-shortcuts +settings:apply-icon +settings:reset-icon +``` -Extend Playwright coverage to verify: +Requests never accept a filesystem path. Every mutation requires the injected session capability. An unauthorized response must not disclose global settings. -- upload opens the crop workflow; -- oversized, SVG, malformed, and over-dimension inputs are rejected; -- preview interactions do not change the launcher before Apply; -- Cancel leaves the old icon unchanged; -- Apply persists and updates without reload; -- reload restores the local override; -- changing the dev-server port preserves the same project override; -- Reset reveals the configured project icon; -- Reset with no configured icon reveals the packaged default; -- broken local, configured-file, remote, and data URL sources show the packaged default rather than a broken image; -- runtime-only mode does not show upload controls; -- remote config URLs receive `no-referrer` behavior; -- transparent output renders correctly in the circular launcher. +## Development and Production Boundaries -Run the flow in the Vite, Webpack, and Next playgrounds. One adapter should own the full interaction matrix; the other adapters can run smaller persistence and capability smoke tests to avoid duplicating slow UI coverage. +Adapter integration remains development-only: -### Production artifact tests +- 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. -Build production fixtures for Vite, Webpack, and Next, then scan emitted client artifacts for forbidden Agentic React runtime markers. This catches regressions where a gate appears correct at configuration time but generated entries still enter the final bundle. +Direct `@agentic-react/core` imports remain an intentional runtime API outside the adapter production guarantee. -## Documentation and Migration +## Verification Strategy -Update the English and Chinese READMEs with: +### Unit and adapter tests -- Settings upload behavior; -- storage location and project-specific semantics; -- format, dimension, and file-size rules; -- `Apply`, `Cancel`, and Reset behavior; -- the `url | file` configuration examples for every adapter; -- `iconUrl` deprecation guidance; -- runtime-only limitations; -- the production-safety guarantee and explicit-development Webpack usage. +Cover: -Add a changeset describing the new customization feature, the backward-compatible configuration API addition, and the safer Webpack production gate. +- 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. -Do not remove `iconUrl` in the same release. Consider removal only in a future major version after documented deprecation. +### Browser E2E -## Delivery Sequence +The Vite playground owns the full interaction matrix: -1. Add the public types, resolved internal types, and compatibility rules. -2. Implement and test the project identity and Node persistence store. -3. Extend the bridge with the narrow project-settings protocol. -4. Resolve configured URL/file sources and initial local overrides in each adapter. -5. Harden the Webpack production gate and add production tests before enabling UI writes. -6. Extract browser image validation, crop math, and encoding modules. -7. Add the Settings appearance row and staged crop workflow. -8. Wire Apply, Reset, preload, source metadata, and direct-default fallback. -9. Add adapter and Playwright coverage across Vite, Webpack, and Next. -10. Update playgrounds, READMEs, changelog metadata, and release smoke coverage. +- 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. -Each sequence item should leave the repository buildable. Land the production-gate hardening before or in the same change that introduces persisted customization. +Smaller adapter tests cover shared engine/bootstrap behavior without duplicating the full browser suite. ## Acceptance Criteria -- A user can upload a supported raster image, crop it to a `256 x 256` static icon, preview it, and persist it only by clicking Apply. -- The override is shared by all dev-server origins and supported bundlers for the same canonical project root on the same machine. -- A different project does not inherit the override. -- Reset removes only the personal override and reveals the project-configured icon or packaged default. -- Bundler configuration supports explicit URL and file sources while legacy `iconUrl` continues to work. -- Remote URL sources are permitted without being proxied or copied. -- Invalid custom sources never produce a broken launcher image and fall back directly to the packaged default. -- Runtime-only usage does not expose a nonfunctional upload flow. -- No user-facing security, origin, token, or customization-enable setting is introduced. -- Vite, Webpack, and Next production client builds contain no adapter-injected Agentic React runtime or icon customization code. -- The feature is documented and covered by unit, adapter, browser, and production-artifact tests. - +- 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/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..26d50e7 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,7 @@ import { interface ToolkitRuntimeOptions { initialConfig?: ToolkitConfig; + settingsClient?: AgenticReactSettingsClient; } interface ToolkitRuntimeResult { @@ -665,6 +688,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, @@ -875,13 +907,21 @@ const buildSourceSnippet = ( export const createSelectionToolkit = ( options: ToolkitRuntimeOptions = {}, ): ToolkitRuntimeResult => { + const settingsClient = options.settingsClient; + 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 +931,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 +1087,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 +1136,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 +1260,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 +1511,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 +1579,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 +1663,8 @@ export const createSelectionToolkit = ( dimElement.style.zIndex = String(Math.max(0, toolkitConfig.zIndex - 3)); } applyTuningModalStaticStyles(); - renderLauncherLogo(); + renderSettingsPanel(); + void renderLauncherLogo(); renderPanelVisibility(); }; @@ -1527,6 +1675,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, @@ -2580,6 +2899,7 @@ export const createSelectionToolkit = ( overlayElement.appendChild(labelElement); overlayElement.appendChild(actions.actionsElement); + stopHostActivationEvents(actions.actionsElement); document.body.appendChild(overlayElement); const overlay = { @@ -2715,10 +3035,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 +3196,21 @@ export const createSelectionToolkit = ( mouseEvent.stopImmediatePropagation(); let didCaptureSelection = false; + const captureSessionId = selectionSessionId; try { const selectionContext = await enrichSelectionContextSourceLocation( await buildSelectionContextForElement(selectedTarget), ); - 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 +3235,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 +3298,12 @@ export const createSelectionToolkit = ( }; const enterMultiSelectionMode = () => { + selectionSessionId += 1; isSelectionMode = true; isMultiSelectionMode = true; clearMultiSelections(); + clearPendingSingleSelection(); clearSelectableElementCache(); - hideSelectedOverlay(); isPanelOpen = true; renderPanelVisibility(); selectButtonElement.textContent = 'Select'; @@ -2961,12 +3317,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 +3333,7 @@ export const createSelectionToolkit = ( hideHoverOverlay(); hideDimOverlay(); clearSelectableElementCache(); + renderMultiSelectionControls(); }; const togglePanel = () => { @@ -3188,10 +3547,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 +3794,9 @@ export const createSelectionToolkit = ( openTuningModal({ anchorElement: selectedActions.tuneButtonElement, element: selectedTargetElement, - selectionContext: lastSelectionContext, + selectionContext: pendingSingleSelectionContext, setSelectionContext: (nextSelectionContext) => { - lastSelectionContext = nextSelectionContext; + pendingSingleSelectionContext = nextSelectionContext; }, }); }); @@ -3209,10 +3804,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 +3859,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 +3948,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..33fb8cf 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,14 @@ 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, }); const runtimeApi = { @@ -42,6 +49,7 @@ const runtimeApi = { selectionToolkit.copyLastSelectionContext(format), registerTuningModalExtension: (extension) => selectionToolkit.registerTuningModalExtension(extension), + settings: settingsClient, }; const builtInTools = { @@ -348,6 +356,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 +452,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 +500,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..d12b7a5 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,18 @@ 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 BridgeRequestEvent = + | RuntimeBridgeRequestEvent + | SettingsBridgeRequestEvent; + 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/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/next/src/index.ts b/packages/next/src/index.ts index fdd6c47..fbcec0e 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,8 +19,9 @@ 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'; interface NextWebpackContext { @@ -38,7 +40,8 @@ interface NextConfig { export interface AgenticReactNextOptions { customTools?: CustomTool[]; rootDir?: string; - toolkit?: ToolkitConfig; + settingsRoot?: string; + toolkit?: AgenticReactToolkitConfig; bridgeUrl?: string; server?: { enabled?: boolean; @@ -50,6 +53,7 @@ export interface AgenticReactNextOptions { interface BridgeServerState { started: boolean; server?: http.Server; + settingsEngine: ReturnType; } const DEFAULT_BRIDGE_HOST = '127.0.0.1'; @@ -65,7 +69,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 +109,7 @@ if (typeof window !== 'undefined') { ...(existingAgenticReactConfig.toolkit || {}), ...${JSON.stringify(toolkitConfig)}, }, + settings: existingAgenticReactConfig.settings || ${JSON.stringify(settingsBootstrap)}, }; if (!window[${__AGENTIC_REACT_BRIDGE_URL__}]) { @@ -169,8 +175,13 @@ 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(); + settingsEngine.registerBridge(runtimeBridge); const handleMcpRequest = createStreamableHttpMcpHandler(() => initMcpServer(runtimeBridge, rootDir, customTools), ); @@ -201,9 +212,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 +244,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 +281,10 @@ const startNextBridgeServer = ( ); }); - return bridgeUrl; + return { + bridgeUrl, + settingsBootstrap: settingsEngine.getBootstrap(), + }; }; export const withAgenticReactNext = ( @@ -262,7 +305,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 +314,7 @@ export const withAgenticReactNext = ( bridgeUrl, options.customTools || [], options.toolkit || {}, + settingsBootstrap, ); const resolvedEntry = typeof originalEntry === 'function' @@ -288,6 +332,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/src/index.ts b/packages/vite/src/index.ts index e3f071f..0358019 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,8 +13,8 @@ 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 type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { type ViteDevServer, normalizePath } from 'vite'; @@ -44,13 +45,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 +68,7 @@ function AgenticReact(options: AgenticReactOptions = {}): Plugin { if (viteDevServer.httpServer) { runtimeBridge.attach(viteDevServer.httpServer); } + settingsEngine.registerBridge(runtimeBridge); const createMcpServer = () => initMcpServer(runtimeBridge, viteDevServer.config.root, customTools); @@ -97,6 +104,7 @@ function AgenticReact(options: AgenticReactOptions = {}): Plugin { window.${__AGENTIC_REACT_CONFIG__} = ${JSON.stringify({ toolkit: toolkitConfig, sourceRoot: config.root, + settings: settingsEngine.getBootstrap(), })}; `; @@ -179,6 +187,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/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..2abd06f 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,9 +18,10 @@ 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 type { Server } from '@modelcontextprotocol/sdk/server/index.js'; @@ -27,10 +29,20 @@ 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 +73,8 @@ const writeWebpackClientEntry = ( rootDir: string, generatedEntryName: string, customTools: CustomTool[], - toolkitConfig: ToolkitConfig, + toolkitConfig: AgenticReactToolkitConfig, + settingsBootstrap: AgenticReactSettingsBootstrap, ): string => { const coreDistPath = getCoreDistPath(); const generatedDirectory = generatedEntryName @@ -102,6 +115,7 @@ if (typeof window !== 'undefined') { ...(existingAgenticReactConfig.toolkit || {}), ...${JSON.stringify(toolkitConfig)}, }, + settings: existingAgenticReactConfig.settings || ${JSON.stringify(settingsBootstrap)}, }; if (!window[${__AGENTIC_REACT_BRIDGE_URL__}]) { @@ -330,8 +344,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 +354,19 @@ 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(); + settingsEngine.registerBridge(runtimeBridge); const createMcpServer = () => initMcpServer(runtimeBridge, rootDir, options.customTools || []); const mcpMiddlewares = [ @@ -395,6 +414,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-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..da38292 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,7 @@ 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 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', From 6d25d7d28ab60341bd5245bad15a3884f51db721 Mon Sep 17 00:00:00 2001 From: jazelly Date: Sun, 26 Jul 2026 22:43:29 +0930 Subject: [PATCH 3/6] feat(toolbox): add settings persistence and source lookup bridge --- .changeset/spotty-bottles-approve.md | 8 + .changeset/tidy-tools-resolve.md | 8 + .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 3 + README.md | 74 ++++++++ package.json | 3 +- packages/core/README.md | 22 +++ packages/core/package.json | 7 +- .../core/src/core/tools/selection_toolkit.ts | 43 +++-- packages/core/src/overlay.js | 2 + packages/core/src/shared/protocol.ts | 5 +- packages/core/src/source_lookup.ts | 162 ++++++++++++++++++ packages/core/test/source-lookup.test.mjs | 92 ++++++++++ packages/next/README.md | 32 ++++ packages/next/src/index.ts | 2 + packages/vite/README.md | 37 ++++ packages/vite/src/index.ts | 2 + packages/webpack/README.md | 37 ++++ packages/webpack/src/index.ts | 135 +-------------- .../e2e/runtime.spec.js | 57 +++++- .../e2e/runtime.spec.js | 76 ++++++-- .../.agentic-react-webpack/client-entry.mjs | 3 +- .../e2e/runtime.spec.js | 35 +++- 23 files changed, 687 insertions(+), 161 deletions(-) create mode 100644 .changeset/spotty-bottles-approve.md create mode 100644 .changeset/tidy-tools-resolve.md create mode 100644 packages/core/src/source_lookup.ts create mode 100644 packages/core/test/source-lookup.test.mjs 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 77c53ff..310b4a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - 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 75671cf..cfe1f2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,6 +150,9 @@ jobs: - 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 diff --git a/README.md b/README.md index 687c280..d82fc58 100644 --- a/README.md +++ b/README.md @@ -162,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/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/core/tools/selection_toolkit.ts b/packages/core/src/core/tools/selection_toolkit.ts index 26d50e7..e65f17a 100644 --- a/packages/core/src/core/tools/selection_toolkit.ts +++ b/packages/core/src/core/tools/selection_toolkit.ts @@ -57,6 +57,10 @@ import { interface ToolkitRuntimeOptions { initialConfig?: ToolkitConfig; settingsClient?: AgenticReactSettingsClient; + sourceLookup?: ( + componentName: string, + selector: string, + ) => Promise; } interface ToolkitRuntimeResult { @@ -833,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; } @@ -908,6 +924,7 @@ 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); @@ -2827,7 +2844,10 @@ export const createSelectionToolkit = ( selectionContext, ); - return enrichSelectionContextSourceLocation(selectionContext); + return enrichSelectionContextSourceLocation( + selectionContext, + sourceLookup, + ); }) .then((selectionContext) => { if (!selectionContext) { @@ -3201,6 +3221,7 @@ export const createSelectionToolkit = ( try { const selectionContext = await enrichSelectionContextSourceLocation( await buildSelectionContextForElement(selectedTarget), + sourceLookup, ); if (captureSessionId !== selectionSessionId || !isSelectionMode) { return; @@ -3377,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; diff --git a/packages/core/src/overlay.js b/packages/core/src/overlay.js index 33fb8cf..91ecee8 100644 --- a/packages/core/src/overlay.js +++ b/packages/core/src/overlay.js @@ -35,6 +35,8 @@ const settingsClient = createAgenticReactSettingsClient({ const selectionToolkit = createSelectionToolkit({ initialConfig: agenticReactConfig.toolkit, settingsClient, + sourceLookup: (componentName, selector) => + requestNodeBridge('source:resolve', { componentName, selector }), }); const runtimeApi = { diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d12b7a5..ff21b44 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -18,9 +18,12 @@ export type SettingsBridgeRequestEvent = | 'settings:reset-icon' | 'settings:reset-shortcuts'; +export type SourceBridgeRequestEvent = 'source:resolve'; + export type BridgeRequestEvent = | RuntimeBridgeRequestEvent - | SettingsBridgeRequestEvent; + | SettingsBridgeRequestEvent + | SourceBridgeRequestEvent; export type BridgeMessage = | { 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/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 fbcec0e..1c48a09 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -23,6 +23,7 @@ import type { AgenticReactToolkitConfig, CustomTool, } from '@agentic-react/core/shared/types'; +import { registerSourceLookupHandler } from '@agentic-react/core/source-lookup'; interface NextWebpackContext { dev: boolean; @@ -181,6 +182,7 @@ const createBridgeHttpServer = ( settingsEngine: ReturnType, ) => { const runtimeBridge = new RuntimeBridgeServer(); + registerSourceLookupHandler(runtimeBridge, rootDir); settingsEngine.registerBridge(runtimeBridge); const handleMcpRequest = createStreamableHttpMcpHandler(() => initMcpServer(runtimeBridge, rootDir, customTools), 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 0358019..191535c 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -16,6 +16,7 @@ import type { AgenticReactToolkitConfig, CustomTool, } 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'; @@ -68,6 +69,7 @@ function AgenticReact(options: AgenticReactOptions = {}): Plugin { if (viteDevServer.httpServer) { runtimeBridge.attach(viteDevServer.httpServer); } + registerSourceLookupHandler(runtimeBridge, viteDevServer.config.root); settingsEngine.registerBridge(runtimeBridge); const createMcpServer = () => 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/src/index.ts b/packages/webpack/src/index.ts index 2abd06f..e9a0037 100644 --- a/packages/webpack/src/index.ts +++ b/packages/webpack/src/index.ts @@ -21,8 +21,11 @@ import type { AgenticReactSettingsBootstrap, AgenticReactToolkitConfig, CustomTool, - SelectionResolvedSource, } 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 { @@ -167,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, @@ -366,6 +240,7 @@ export const withAgenticReactWebpack = ( settingsEngine.getBootstrap(), ); const runtimeBridge = new RuntimeBridgeServer(); + registerSourceLookupHandler(runtimeBridge, rootDir); settingsEngine.registerBridge(runtimeBridge); const createMcpServer = () => initMcpServer(runtimeBridge, rootDir, options.customTools || []); 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-webpack-playground/.agentic-react-webpack/client-entry.mjs b/playground/agentic-react-webpack-playground/.agentic-react-webpack/client-entry.mjs index d780609..45801ae 100644 --- a/playground/agentic-react-webpack-playground/.agentic-react-webpack/client-entry.mjs +++ b/playground/agentic-react-webpack-playground/.agentic-react-webpack/client-entry.mjs @@ -9,11 +9,12 @@ 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", + sourceRoot: existingAgenticReactConfig.sourceRoot || "/Users/jazelly/Desktop/github/my-proj/agentic-react/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"}}}}, }, + settings: existingAgenticReactConfig.settings || {"effectiveSettings":{"schemaVersion":1,"shortcuts":{"singleSelect":"Ctrl+Alt+Shift+S","multiSelect":"Ctrl+Alt+Shift+M","toggleToolbox":"Ctrl+Alt+Shift+A","done":"Enter"},"appearance":{"toolboxIcon":null,"toolboxIconUrl":null}},"sources":{"shortcuts":{"singleSelect":"package","multiSelect":"package","toggleToolbox":"package","done":"package"},"appearance":{"toolboxIcon":"package"}},"errors":[],"capability":{"available":true,"token":"iU0ytb5IErWgq7vuuSfwKoX5t1Xu2vHIqkSI28LXcx0"}}, }; if (!window[__AGENTIC_REACT_BRIDGE_URL__]) { 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(), From bcb232e1a44655c13682e7831cd8cd96867a6cea Mon Sep 17 00:00:00 2001 From: jazelly Date: Sun, 26 Jul 2026 23:17:45 +0930 Subject: [PATCH 4/6] chore(playground): stop tracking generated webpack entry --- .../.agentic-react-webpack/client-entry.mjs | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 playground/agentic-react-webpack-playground/.agentic-react-webpack/client-entry.mjs 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 45801ae..0000000 --- a/playground/agentic-react-webpack-playground/.agentic-react-webpack/client-entry.mjs +++ /dev/null @@ -1,48 +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/agentic-react/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"}}}}, - }, - settings: existingAgenticReactConfig.settings || {"effectiveSettings":{"schemaVersion":1,"shortcuts":{"singleSelect":"Ctrl+Alt+Shift+S","multiSelect":"Ctrl+Alt+Shift+M","toggleToolbox":"Ctrl+Alt+Shift+A","done":"Enter"},"appearance":{"toolboxIcon":null,"toolboxIconUrl":null}},"sources":{"shortcuts":{"singleSelect":"package","multiSelect":"package","toggleToolbox":"package","done":"package"},"appearance":{"toolboxIcon":"package"}},"errors":[],"capability":{"available":true,"token":"iU0ytb5IErWgq7vuuSfwKoX5t1Xu2vHIqkSI28LXcx0"}}, - }; - - 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(); - }; - - - From b64701220f5540da2b29b0e77712ac360934b373 Mon Sep 17 00:00:00 2001 From: jazelly Date: Mon, 27 Jul 2026 04:12:24 +0930 Subject: [PATCH 5/6] fix(ci): isolate release smoke git fixtures --- scripts/release-smoke-test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/release-smoke-test.mjs b/scripts/release-smoke-test.mjs index 2bd9922..c2e0949 100644 --- a/scripts/release-smoke-test.mjs +++ b/scripts/release-smoke-test.mjs @@ -489,11 +489,16 @@ const makeVersionedReleaseFixture = (workspace, name, tamperPlan = false) => { ); run('git', ['clone', '--quiet', '--no-local', rootDir, repo]); - run('git', ['checkout', '-B', 'main', 'origin/main'], { cwd: 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', From 35654241eda3682aaf045e4fbc6b35866967ba39 Mon Sep 17 00:00:00 2001 From: jazelly Date: Mon, 27 Jul 2026 04:43:31 +0930 Subject: [PATCH 6/6] fix(e2e): wait for pending selection before commit --- .../e2e/selection-context.spec.js | 8 ++++++++ 1 file changed, 8 insertions(+) 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 da38292..b09a1ac 100644 --- a/playground/agentic-react-vite-playground/e2e/selection-context.spec.js +++ b/playground/agentic-react-vite-playground/e2e/selection-context.spec.js @@ -1259,6 +1259,14 @@ 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( () =>